前言
秋招金九银十快到了,发现网上很多Java面试题都没有答案,且杂乱无章,所以花了很长时间搜集整理出来了这套Java面试题大全~
这套互联网 Java 工程师面试题包括了:最新Java技术场景题、Java业务场景题、MyBatis、ZK、Dubbo、EL、Redis、MySQL、并发编程、Java面试、Spring、微服务、Linux、Springboot、SpringCloud、MQ、Kafka 等面试专题
一、📌Java技术场景题
1.1 如何在生产环境不停服情况下进行数据迁移,从原来的16张表迁移到64张表中?
1.1.1 面试考察重点
面试官提出这个问题主要考察以下几个核心能力:
- 💡 系统架构能力:能否设计出高可用、低风险的迁移方案
- 💡 分布式系统理解:对数据一致性、事务处理的理解深度
- 💡 实战经验:是否有真实的大规模数据迁移经验
- 💡 风险控制意识:对迁移过程中可能出现的问题是否有预见性和解决方案
- 💡 技术广度:对相关工具链和技术方案的熟悉程度
问题的关键关注点:
- 💡 零停机/最小停机:如何保证业务持续可用是关键要求48
- 💡 数据一致性:迁移过程中新旧数据如何保持一致4
- 💡 性能影响:迁移过程对生产系统性能的影响控制2
- 💡 回滚机制:出现问题时如何快速回滚8
- 💡 验证方案:如何验证迁移的正确性和完整性4
1.1.2 面试题核心知识点详解
你可能会认为:数据迁移无非是将数据从一个数据库拷贝到另一个数据库,可以通过 MySQL 主从同步的方式做到准实时的数据拷贝;也可以通过 mysqldump 工具将源库的数据导出,再导入到新库,这有什么复杂的呢?
其实,这两种方式只能支持单库到单库的迁移,无法支持单库到多库多表的场景。而且即便是单库到单库的迁移,迁移过程也需要满足以下几个目标:
迁移应该是在线的迁移,也就是在迁移的同时还会有数据的写入;
数据应该保证完整性,也就是说在迁移之后需要保证新的库和旧的库的数据是一致的;
迁移的过程需要做到可以回滚,这样一旦迁移的过程中出现问题,可以立刻回滚到源库,不会对系统的可用性造成影响。
如果你使用 Binlog 同步的方式,在同步完成后再修改代码,将主库修改为新的数据库,这样就不满足可回滚的要求,一旦迁移后发现问题,由于已经有增量的数据写入了新库而没有写入旧库,不可能再将数据库改成旧库。
一般来说,我们有两种方案可以做数据库的迁移。
1.1.2.1"双写"方案
第一种方案我称之为双写,其实说起来也很简单,它可以分为以下几个步骤:
-
将新的库配置为源库的从库,用来同步数据;如果需要将数据同步到多库多表,那么可以使用一些第三方工具获取 Binlog 的增量日志(比如开源工具 Canal),在获取增量日志之后就可以按照分库分表的逻辑写入到新的库表中了。
-
同时,我们需要改造业务代码,在数据写入的时候,不仅要写入旧库,也要写入新库。当然,基于性能的考虑,我们可以异步地写入新库,只要保证旧库写入成功即可。******但是,我们需要注意的是,******需要将写入新库失败的数据记录在单独的日志中,这样方便后续对这些数据补写,保证新库和旧库的数据一致性。
-
然后,我们就可以开始校验数据了。由于数据库中数据量很大,做全量的数据校验不太现实。你可以抽取部分数据,具体数据量依据总体数据量而定,只要保证这些数据是一致的就可以。
-
如果一切顺利,我们就可以将读流量切换到新库了。由于担心一次切换全量读流量可能会对系统产生未知的影响,所以这里******最好采用灰度的方式来切换,******比如开始切换 10% 的流量,如果没有问题再切换到 50% 的流量,最后再切换到 100%。
-
由于有双写的存在,所以在切换的过程中出现任何的问题,都可以将读写流量随时切换到旧库去,保障系统的性能。
-
在观察了几天发现数据的迁移没有问题之后,就可以将数据库的双写改造成只写新库,数据的迁移也就完成了。
******其中,最容易出问题的步骤就是数据校验的工作,******所以,我建议你在未开始迁移数据之前先写好数据校验的工具或者脚本,在测试环境上测试充分之后,再开始正式的数据迁移。
如果是将数据从自建机房迁移到云上,你也可以使用这个方案,******只是你需要考虑的一个重要的因素是:******自建机房到云上的专线的带宽和延迟,你需要尽量减少跨专线的读操作,所以在切换读流量的时候,你需要保证自建机房的应用服务器读取本机房的数据库,云上的应用服务器读取云上的数据库。这样在完成迁移之前,只要将自建机房的应用服务器停掉,并且将写入流量都切到新库就可以了。
这种方案是一种比较通用的方案,无论是迁移 MySQL 中的数据,还是迁移 Redis 中的数据,甚至迁移消息队列都可以使用这种方式,你在实际的工作中可以直接拿来使用。
这种方式的好处是: 迁移的过程可以随时回滚,将迁移的风险降到了最低。******劣势是:******时间周期比较长,应用有改造的成本。
1.1.2.2 级联同步方案
这种方案也比较简单,比较适合数据从自建机房向云上迁移的场景。因为迁移上云,最担心云上的环境和自建机房的环境不一致,会导致数据库在云上运行时,因为参数配置或者硬件环境不同出现问题。
所以,我们会在自建机房准备一个备库,在云上环境上准备一个新库,通过级联同步的方式在自建机房留下一个可回滚的数据库,具体的步骤如下:
-
先将新库配置为旧库的从库,用作数据同步;
-
再将一个备库配置为新库的从库,用作数据的备份;
-
等到三个库的写入一致后,将数据库的读流量切换到新库;
-
然后暂停应用的写入,将业务的写入流量切换到新库(由于这里需要暂停应用的写入,所以需要安排在业务的低峰期)。
******这种方案的回滚方案也比较简单,******可以先将读流量切换到备库,再暂停应用的写入,将写流量切换到备库,这样所有的流量都切换到了备库,也就是又回到了自建机房的环境,就可以认为已经回滚了。
上面的级联迁移方案可以应用在,将 MySQL 从自建机房迁移到云上的场景,也可以应用在将 Redis 从自建机房迁移到云上的场景,如果你有类似的需求可以直接拿来应用。
这种方案优势是 简单易实施,在业务上基本没有改造的成本;缺点是在切写的时候需要短暂的停止写入,对于业务来说是有损的,不过如果在业务低峰期来执行切写,可以将对业务的影响降至最低。
另外,在从自建机房向云上迁移数据时,我们也需要考虑缓存的迁移方案是怎样的。那么你可能会说:缓存本来就是作为一个中间的存储而存在的,我只需要在云上部署一个空的缓存节点,云上的请求也会穿透到云上的数据库,然后回种缓存,对于业务是没有影响的。
你说的没错,但是你还需要考虑的是缓存的命中率。
如果你部署一个空的缓存,那么所有的请求就都穿透到数据库,数据库可能因为承受不了这么大的压力而宕机,这样你的服务就会不可用了。所以,缓存迁移的重点是保持缓存的热度。
刚刚我提到,Redis 的数据迁移可以使用双写的方案或者级联同步的方案,所以在这里我就不考虑 Redis 缓存的同步了,而是以 Memcached 为例来说明。
1.1.2.3 使用副本组预热缓存
在"缓存的使用姿势(二):缓存如何做到高可用?"中,我曾经提到,为了保证缓存的可用性,我们可以部署多个副本组来尽量将请求阻挡在数据库层之上。
数据的写入流程是写入 Master、Slave 和所有的副本组,而在读取数据的时候,会先读副本组的数据,如果读取不到再到 Master 和 Slave 里面加载数据,再写入到副本组中。******那么,我们就可以在云上部署一个副本组,******这样,云上的应用服务器读取云上的副本组,如果副本组没有查询到数据,就可以从自建机房部署的主从缓存上加载数据,回种到云上的副本组上。
当云上部署的副本组足够热之后,也就是缓存的命中率达到至少 90%,就可以将云机房上的缓存服务器的主从都指向这个副本组,这时迁移也就完成了。
******这种方式足够简单,不过有一个致命的问题是:******如果云上的请求穿透云上的副本组,到达自建机房的主从缓存时,这个过程是需要跨越专线的。
这不仅会占用较多专线的带宽,同时专线的延迟相比于缓存的读取时间是比较大的,一般,即使是本地的不同机房之间的延迟也会达到 2ms~3ms,那么,一次前端请求可能会访问十几次甚至几十次的缓存,一次请求就会平白增加几十毫秒甚至过百毫秒的延迟,会极大地影响接口的响应时间,因此在实际项目中我们很少使用这种方案。
******但是,这种方案给了我们思路,******让我们可以通过方案的设计在系统运行中自动完成缓存的预热,所以,我们对副本组的方案做了一些改造,以尽量减少对专线带宽的占用。
1.1.2.4 改造副本组方案预热缓存
改造后的方案对读写缓存的方式进行改造,步骤是这样的:
-
在云上部署多组 mc 的副本组,自建机房在接收到写入请求时,会优先写入自建机房的缓存节点,异步写入云上部署的 mc 节点;
-
在处理自建机房的读请求时,会指定一定的流量,比如 10%,优先走云上的缓存节点,这样虽然也会走专线穿透回自建机房的缓存节点,但是流量是可控的;
-
当云上缓存节点的命中率达到 90% 以上时,就可以在云上部署应用服务器,让云上的应用服务器完全走云上的缓存节点就可以了。
使用了这种方式,我们可以实现缓存数据的迁移,又可以尽量控制专线的带宽和请求的延迟情况,你也可以直接在项目中使用。
1.1.3 面试前准备建议和思路
- 深入理解分库分表原理:特别是分片策略和路由算法
- 熟悉至少一种数据同步工具:如Canal、DRS等
- 准备实战案例:即使没有实际经验,也要准备一个完整的学习案例
- 了解一致性解决方案:如分布式事务、最终一致性等
- 思考极端情况:如迁移过程中出现数据不一致如何处理
通过这样系统性的准备,你能够展示出全面的技术能力和严谨的工程思维,大大增加面试通过的概率。
记住,面试官不仅考察你的技术知识,更看重你解决问题的思路和方法论。
回答结构建议
1.1.4 📌面试必过的回答思路
- 总述:简要说明问题复杂性和解决思路
- 详细方案:分步骤阐述具体实施方案
- 风险控制:说明如何保证迁移安全
- 工具选择:提及可能用到的工具和技术
- 经验分享:如有相关经验可简要分享
✅ 标准回答示例
🔔
"在生产环境不停服的情况下将数据从16张表迁移到64张表,这是一个典型的在线分库分表扩容场景。我会采用四阶段双写迁移方案来确保业务连续性和数据一致性。
第一阶段:双写准备
首先,我会修改应用代码,使所有写操作同时写入新旧表结构。
对于从16表到64表的映射,会设计明确的分片策略,比如基于用户ID哈希。
同时开发后台迁移任务,逐步将历史数据从旧表迁移到新表,采用批量+增量的方式,避免一次性全量迁移对系统造成过大压力。
这一阶段会持续监控数据库性能指标。
第二阶段:读切换
当确认新旧表数据基本一致后,我会逐步将读请求切换到新表。可以使用A/B测试的方式,先对少量流量进行切换,同时使用数据对比工具确保查询结果一致。
GitHub的Scientist库是不错的选择,它能对比新旧查询结果并报告差异。
确认无误后,最终将所有读操作切换到新表。
第三阶段:写切换
读操作完全切换后,开始将写操作也切换到新表。此时仍然保持反向同步到旧表,作为回滚保障。
这一阶段要特别注意事务一致性,可能需要引入分布式事务或最终一致性方案。
同时加强监控,确保没有数据不一致的情况。
第四阶段:清理
确认业务完全运行在新表上且稳定后,移除旧表相关代码,择机下线旧表。
最后进行全面的数据校验,确保迁移完整无误。
风险控制方面:
- 实施前做好完整备份
- 每个阶段都有可回滚方案
- 设置详细监控指标和报警机制
- 在低峰期执行数据迁移操作
- 准备应急预案
工具选择:
根据实际情况,可以考虑使用DRS进行实时数据同步,或使用ShardingSphere处理分片路由。
对于特别大的数据量,可以采用Hadoop进行离线数据处理。
在实际项目中,我曾主导过一次类似的从单表到16分表的迁移,采用的就是这种渐进式方案,最终实现了零停机迁移,业务无感知。"
回答加分项
- 提及具体工具的使用经验
- 分享实际迁移中的数据量、耗时等细节
- 讨论遇到的具体问题及解决方案
- 展示对性能影响的分析和优化思路
- 强调监控和回滚机制的重要性
1.2 订单到期关单如何实现
1.2.1 面试考察重点
1. 面试官的考察目标
- 定时任务设计能力:能否设计可靠的定时关单机制
- 分布式系统思维:在分布式环境下如何处理定时任务
- 事务一致性:关单过程中的数据一致性保障
- 性能考量:大批量订单处理时的性能优化
- 异常处理:对失败情况的处理策略
- 实时性要求:如何平衡精确性和系统负载
2. 关键关注点
- 触发机制:如何准确触发到期关单
- 关单逻辑:关单的业务流程和数据变更
- 并发控制:防止重复关单或漏关单
- 性能扩展:海量订单情况下的处理能力
- 补偿机制:失败后的重试或报警策略
1.2.2 面试核心知识点详解
📌
日常开发中,我们经常遇到这种业务场景,如:外卖订单超 30 分钟未支付,则自动取订单;用户注册成功 15 分钟后,发短信息通知用户等等。这就延时任务处理场景。
在电商,支付等系统中,一设都是先创建订单(支付单),再给用户一定的时间进行支付,如果没有按时支付的话,就需要把之前的订单(支付单)取消掉。这种类以的场景有很多,还有比如到期自动收货,超时自动退款,下单后自动发送短信等等都是类似的业务问题。
1.2.2.1 定时任务
通过定时任务关闭订单,是一种成本很低,实现也很容易的方案。通过简单的几行代码,写一个定时任务,定期扫描数据库中的订单,如果时间过期,就将其状态更新为关闭即可。
优点:实现容易,成本低,基本不依赖其他组件。
缺点:
时间可能不够精确。由于定时任务扫描的间隔是固定的,所以可能造成一些订单已经过期了一段时间才被扫描到,订单关闭的时间比正常时间晚一些。
增加了数据库的压力。随着订单的数量越来越多,扫描的成本也会越来越大,执行时间也会被拉长,可能导致某些应该被关闭的订单迟迟没有被关闭。
总结 :采用定时任务的方案比较适合对时间要求不是很敏感,并且数据量不太多的业务场景。
1.2.2.2 JDK 延迟队列 DelayQueue
DelayQueue 是 JDK 提供的一个无界队列,我们可以看到,DelayQueue 队列中的元素需要实现 Delayed,它只提供了一个方法,就是获取过期时间。
用户的订单生成以后,设置过期时间比如 30 分钟,放入定义好的 DelayQueue,然后创建一个线程,在线程中通过 while(true)不断的从 DelayQueue 中获取过期的数据。
优点:不依赖任何第三方组件,连数据库也不需要了,实现起来也方便。
缺点:
因为 DelayQueue 是一个无界队列,如果放入的订单过多,会造成 JVM OOM。
DelayQueue 基于 JVM 内存,如果 JVM 重启了,那所有数据就丢失了。
总结 :DelayQueue 适用于数据量较小,且丢失也不影响主业务的场景,比如内部系统的一些非重要通知,就算丢失,也不会有太大影响。
1.2.2.3 redis 过期监听
redis 是一个高性能的 KV 数据库,除了用作缓存以外,其实还提供了过期监听的功能。
在 redis.conf 中,配置 notify-keyspace-events Ex 即可开启此功能。
然后在代码中继承 KeyspaceEventMessageListener,实现 onMessage 就可以监听过期的数据量。
public abstract class KeyspaceEventMessageListener implements MessageListener, InitializingBean, DisposableBean {
private static final Topic TOPIC_ALL_KEYEVENTS = new PatternTopic("__keyevent@*");
//...省略部分代码
public void init() {
if (StringUtils.hasText(keyspaceNotificationsConfigParameter)) {
RedisConnection connection = listenerContainer.getConnectionFactory().getConnection();
try {
Properties config = connection.getConfig("notify-keyspace-events");
if (!StringUtils.hasText(config.getProperty("notify-keyspace-events"))) {
connection.setConfig("notify-keyspace-events", keyspaceNotificationsConfigParameter);
}
} finally {
connection.close();
}
}
doRegister(listenerContainer);
}
protected void doRegister(RedisMessageListenerContainer container) {
listenerContainer.addMessageListener(this, TOPIC_ALL_KEYEVENTS);
}
//...省略部分代码
@Override
public void afterPropertiesSet() throws Exception {
init();
}
}
通过以上源码,我们可以发现,其本质也是注册一个 listener,利用 redis 的发布订阅,当 key 过期时,发布过期消息(key)到 Channel :keyevent@*:expired 中。
在实际的业务中,我们可以将订单的过期时间设置比如 30 分钟,然后放入到 redis。30 分钟之后,就可以消费这个 key,然后做一些业务上的后置动作,比如检查用户是否支付。
优点: 由于 redis 的高性能,所以我们在设置 key,或者消费 key 时,速度上是可以保证的。
缺点:由于 redis 的 key 过期策略原因,当一个 key 过期时,redis 无法保证立刻将其删除,自然我们的监听事件也无法第一时间消费到这个 key,所以会存在一定的延迟。另外,在 redis5.0 之前,订阅发布中的消息并没有被持久化,自然也没有所谓的确认机制。所以一旦消费消息的过程中我们的客户端发生了宕机,这条消息就彻底丢失了。
总结:redis 的过期订阅相比于其他方案没有太大的优势,在实际生产环境中,用得相对较少。
1.2.2.4 Redisson 分布式延迟队列
Redisson 是一个基于 redis 实现的 Java 驻内存数据网格,它不仅提供了一系列的分布式的 Java 常用对象,还提供了许多分布式服务。
Redisson 除了提供我们常用的分布式锁外,还提供了一个分布式延迟队列 RDelayedQueue,他是一种基于 zset 结构实现的延迟队列,其实现类是 RedissonDelayedQueue。
优点:使用简单,并且其实现类中大量使用 lua 脚本保证其原子性,不会有并发重复问题。
缺点:需要依赖 redis(如果这算一种缺点的话)。
总结:Redisson 是 redis 官方推荐的 JAVA 客户端,提供了很多常用的功能,使用简单、高效,推荐大家尝试使用。
1.2.2.5 RocketMQ 延迟消息
延迟消息,当消息写入到 Broker 后,不会立刻被消费者消费,需要等待指定的时长后才可被消费处理的消息,称为延时消息。
在订单创建之后,我们就可以把订单作为一条消息投递到 rocketmq,并将延迟时间设置为 30 分钟,这样,30 分钟后我们定义的 consumer 就可以消费到这条消息,然后检查用户是否支付了这个订单。
通过延迟消息,我们就可以将业务解耦,极大地简化我们的代码逻辑。
优点:可以使代码逻辑清晰,系统之间完全解耦,只需关注生产及消费消息即可。另外其吞吐量极高,最多可以支撑万亿级的数据量。
缺点:相对来说 mq 是重量级的组件,引入 mq 之后,随之而来的消息丢失、幂等性问题等都加深了系统的复杂度。
总结:通过 mq 进行系统业务解耦,以及对系统性能削峰填谷已经是当前高性能系统的标配。
1.2.2.6 RabbitMQ 死信队列
除了 RocketMQ 的延迟队列,RabbitMQ 的死信队列也可以实现消息延迟功能。
当 RabbitMQ 中的一条正常消息,因为过了存活时间(TTL 过期)、队列长度超限、被消费者拒绝等原因无法被消费时,就会被当成一条死信消息,投递到死信队列。
基于这样的机制,我们可以给消息设置一个 ttl,然后故意不消费消息,等消息过期就会进入死信队列,我们再消费死信队列即可。
通过这样的方式,就可以达到同 RocketMQ 延迟消息一样的效果。
优点:同 RocketMQ 一样,RabbitMQ 同样可以使业务解耦,基于其集群的扩展性,也可以实现高可用、高性能的目标。
缺点:死信队列本质还是一个队列,队列都是先进先出,如果队头的消息过期时间比较长,就会导致后面过期的消息无法得到及时消费,造成消息阻塞。
总结:除了增加系统复杂度之外,死信队列的阻塞问题也是需要我们重点关注的。
1.2.3 面试前准备建议和思路
面试官可能会追问以下问题,建议提前准备:
- 如何避免漏关单?
- 答案:双重保障机制(延迟消息+定时补偿),设置合理的重试策略,关键指标监控
- 海量订单时如何优化?
- 答案:分片处理、批量操作、读写分离、热点订单特殊处理
- 关单操作涉及多个服务如何保证一致性?
- 答案:SAGA模式、本地消息表、最大努力通知等分布式事务方案
- 如何测试关单功能的正确性?
- 答案:时间模拟测试、混沌工程注入故障、对账系统验证
- 关单时间精度要求很高怎么办?
- 答案:本地时间轮辅助、更小的延迟粒度、优先级队列
1.2.4 📌面试必过的回答思路
💡
1. 标准回答示例
"订单到期关单是电商系统的典型场景,我会采用延迟队列为主、定时任务补偿的双保险方案来确保可靠性和实时性。
核心设计分为三个部分:
- 订单创建时的延迟消息投递
当订单创建时,系统会计算订单的到期时间(如30分钟未支付),然后向RocketMQ延迟队列发送一条延迟消息。我们选择RocketMQ是因为它支持精准的延迟级别,且经过了大规模实践验证。消息体包含订单ID、创建时间和预期关单时间。 - 消息消费与关单执行
消息消费者接收到到期消息后,会先通过分布式锁(Redis)获取该订单的处理权,防止集群环境下重复消费。然后查询订单当前状态,只有待支付状态才会执行关单:
- 更新订单状态为"已关闭"
- 释放库存占用
- 记录操作日志
- 可选:通知用户
所有操作包裹在本地事务中,确保原子性。关单成功后记录处理时间,失败则进入重试队列。
- 定时补偿机制
考虑到消息可能丢失或消费失败,我们额外部署定时任务,每5分钟扫描一次待关单订单(状态为待支付且创建时间超过阈值的)。发现异常订单后,优先尝试重新投递延迟消息,如果多次失败则触发人工干预报警。
技术细节优化:
- 分片处理:大促时采用订单ID哈希分片,多消费者并行处理
- 幂等设计:关单前检查状态,避免重复关单
- 压力控制:动态调整消费者数量,防止数据库过载
- 监控报警:关单延迟、失败率等关键指标监控
异常情况处理:
- 消息堆积:自动增加消费者实例
- 数据库超时:降级为异步重试
- 分布式锁失效:采用CAS乐观锁替代
在实际项目中,这套方案支持了我们日均百万级订单的关单需求,关单准确率达到99.99%,异常订单能在5分钟内被补偿机制捕获。"
2. 回答加分项
- 对比不同方案优劣(如纯定时任务扫描的局限性)
- 提及具体中间件的使用细节(如RocketMQ延迟级别设置)
- 讨论分布式环境下的挑战和解决方案
- 分享实际业务中的性能数据或调优经验
- 展示监控指标设计和报警策略
1.3 为什么MySQL用B+树,MongoDB用B树?
1.3.1 面试考察重点
1. 面试官的考察目标
面试官提出这个问题主要想评估以下几个方面的能力:
- 存储引擎底层原理理解:对B树和B+树数据结构的深入理解
- 数据库设计哲学把握:理解不同数据库的设计目标和技术选型依据
- 工程实践思维:能否将理论知识与实际系统设计联系起来
- 技术对比分析能力:能够客观分析不同技术方案的优缺点
- 系统设计全局观:理解索引选择对整体系统性能的影响
2. 问题的关键关注点
- 数据结构差异:B树与B+树在节点结构、数据存储方式上的区别
- 访问模式差异:两种数据库典型的查询模式(点查询vs范围查询)
- 存储介质考量:磁盘I/O特性对数据结构选择的影响
- 使用场景差异:关系型数据库与文档数据库的不同需求
- 性能权衡:查询性能、写入性能、存储空间等方面的权衡
1.3.2 面试核心知识点详解
B+ tree 实际上是一颗m叉平衡查找树(不是二叉树)
平衡查找树定义:树中任意一个节点的左右子树的高度相差不能大于 1
在B+ 树中,树中的节点并不存储数据本身,而是只是作为索引。除此之外,所有记录的节点按大小顺序存储在同一层的叶节点中,并且每个叶节点通过指针连接。
总结下,B+树有以下特点
- B +树的每个节点可以包含更多节点,其原因有两个,其一是降低树的高度(索引不会全部存储在内存中,内存中可能撑不住,所以一般都是将索引树存储在磁盘中,只是将根节点放到内存中,这样对每个节点的访问,实际上就是访问磁盘,树的高度就等于每次查询数据时磁盘 IO 操作的次数),另一种是将数据范围更改为多个间隔。间隔越大,数据检索越快(可以想象跳表)
- 每个节点不在是存储一个key,而是存储多个key
- 叶节点来存储数据,而其他节点用于索引
- 叶子节点通过两个指针相互链接,顺序查询性能更高。
这样设计还有以下优点:
- B +树的非叶子节点仅存储键,占用很小的空间,因此节点的每一层可以索引的数据范围要宽得多。换句话说,可以为每个IO操作搜索更多数据
- 叶子节点成对连接,符合磁盘的预读特性。例如,叶节点存储50和55,它们具有指向叶节点60和62的指针。当我们从磁盘读取对应于50和55的数据时,由于磁盘的预读特性,我们将顺便提一下60和62。读出相应的数据。这次是顺序读取,而不是磁盘搜索,加快了速度。
- 支持范围查询,局部范围查询非常高效,每个节点都可以索引更大,更准确的范围,这意味着B +树单磁盘IO信息大于B树,并且I / O效率更高
- 由于数据存储在叶节点层中,并且有指向其他叶节点的指针,因此范围查询仅需要遍历叶节点层,而无需遍历整个树。
由于磁盘访问速度和内存之间存在差距,为了提高效率,应将磁盘I / O最小化。磁盘通常不是严格按需读取的,而是每次都被预读。磁盘读取所需的数据后,它将向后读取内存中的一定长度的数据。
B-Tree实际上也是一颗m叉平衡查找树
- 所有的key值分布在整个树中
- 所有的key值出现在一个节点中
- 搜索可以在非叶子节点处结束
- 在完整的关键字搜索过程中,性能接近二分搜索。
1.3.2.1 B树和B+树之间的区别
- B +树中的非叶子节点不存储数据,并且存储在叶节点中的所有数据使得查询时间复杂度固定为log n。
- B树查询时间的复杂度不是固定的,它与键在树中的位置有关,最好是O(1)。
- 由于B+树的叶子节点是通过双向链表链接的,所以支持范围查询,且效率比B树高
- B树每个节点的键和数据是一起的
1.3.2.2 为什么MongoDB使用B-Tree,Mysql使用B+Tree ?
B +树中的非叶子节点不存储数据,并且存储在叶节点中的所有数据使得查询时间复杂度固定为log n。B树查询时间复杂度不是固定的,它与键在树中的位置有关,最好是O(1)。
我们已经说过,尽可能少的磁盘IO是提高性能的有效方法。MongoDB是一个聚合数据库,而B树恰好是键域和数据域的集群。
至于为什么MongoDB使用B树而不是B +树,可以从其设计的角度考虑它。
MongoDB不是传统的关系数据库,而是以BSON格式(可以认为是JSON)存储的nosql。目的是高性能,高可用性和易于扩展。
Mysql是关系型数据库,最常用的是数据遍历操作(join),而MongoDB它的数据更多的是聚合过的数据,不像Mysql那样表之间的关系那么强烈,因此MongoDB更多的是单个查询。
由于Mysql使用B+树,数据在叶节点上,叶子节点之间又通过双向链表连接,更加有利于数据遍历,而MongoDB使用B树,所有节点都有一个数据字段。只要找到指定的索引,就可以对其进行访问。毫无疑问,单个查询MongoDB平均查询速度比Mysql快。
1.3.3 面试前准备建议和思路
1. 深入理解B树和B+树
- B树核心特点 :
- 所有节点都存储数据
- 每个key都有对应的data域
- 查询路径不固定,最好情况O(1)
- 适合随机点查询510
- B+树核心特点 :
- 只有叶子节点存储数据,内部节点只存key
- 叶子节点通过指针链接形成有序链表
- 查询路径固定O(log n)
- 适合范围查询和顺序访问56
2. 掌握两种数据库的典型使用场景
- MySQL(关系型数据库) :
- 多表关联查询
- 复杂事务处理
- 范围查询和排序操作频繁
- 数据一致性要求高24
- MongoDB(文档数据库) :
- 单文档操作居多
- 嵌套文档结构
- 高吞吐写入
- 灵活的模式设计28
3. 准备实际案例和数据
- 收集B+树在范围查询中的性能数据
- 准备B树在点查询中的性能优势案例
- 了解两种结构在磁盘I/O次数上的差异
4. 思考可能的延伸问题
- 如果MongoDB要优化范围查询可以怎么做?
- 在SSD时代这种选择还适用吗?
- 如何处理热点数据访问问题?
面试官可能会追问:
- 为什么Redis用跳表而不用B树?
- 答案:内存数据结构更看重简单性和并发性能,跳表实现更简单且无旋转操作
- PostgreSQL为什么可以用B树实现主要索引?
- 答案:PG的堆表结构与MySQL不同,其索引都是二级索引,且有针对B树的特殊优化
- B+树在SSD时代还适用吗?
- 答案:SSD改变了随机/顺序访问的代价比,但B+树的结构优势仍然存在,LSM树在纯SSD场景更有优势
- MongoDB如何优化范围查询?
- 答案:通过组合索引、覆盖查询等,4.4版本后新增了列存引擎支持分析场景
1.3.4 📌面试必过的回答思路
💡
"MySQL选择B+树而MongoDB选择B树,主要源于两者不同的设计目标和应用场景:
MySQL作为关系型数据库,其核心场景需要:
- 高效的范围查询和全表扫描 → B+树的叶子节点链表完美支持
- 优化的磁盘I/O性能 → B+树更高的节点分支因子减少磁盘访问
- 事务和索引支持 → B+树的清晰层次结构便于实现锁和MVCC
而MongoDB作为文档数据库,更关注: - 单文档随机访问性能 → B树可能在非叶子节点就找到目标文档
- 内存缓存友好性 → B树存储完整文档利于缓存局部性
- 文档可变大小支持 → B树比B+树更容易处理变长数据
实际工程中,MySQL的InnoDB通过B+树实现了高度优化的磁盘存储结构,而MongoDB的WiredTiger引擎则基于B树进行了内存优化。两者都是各自场景下的最佳选择。"
1.4 如果让你实现消息队列,会考虑哪些问题?
1.4.1 面试考察重点
- 面试官的考察目标
- 系统设计能力:能否设计高可用、高性能的分布式系统
- 消息队列原理理解:对消息队列核心机制的理解深度
- 工程权衡思维:在不同约束条件下做出合理技术选择
- 问题预见性:对可能的问题和挑战是否有充分认知
- 技术广度:对相关中间件和算法的了解程度
- 关键关注点
- 消息可靠性:如何保证消息不丢失
- 消息顺序性:如何保证消息有序消费
- 系统可用性:如何实现高可用架构
- 性能设计:如何支持高吞吐、低延迟
- 扩展性:如何应对流量增长
- 运维考量:监控、告警等运维支持
1.4.2 面试核心知识点详解
- Topic 主题
在 Kafka 中,使用一个类别属性来划分消息的所属类,划分消息的这个类称为 Topic。Topic 相当于消息的分类标签,是一个逻辑概念。
物理上不同 Topic 的消息分开存储,逻辑上一个 Topic 的消息虽然保存于一个或多个 Broker 上但用户只需指定消息的 Topic 即可生产或消费数据而不必关心数据存于何处。
生产者负责将消息发送到特定的主题(发送到 Kafka 集群中的每一条消息都要指定一个主题),而消费者负责订阅主题并进行消费。
- Partition:分区
Topic 中的消息被分割为一个或多个 Partition,其是一个物理概念,对应到系统上 就是一个或若干个目录。Partition 内部的消息是有序的,但 Partition 间的消息是无序的。
- Segment 段
将 Partition 进一步细分为了若干的 Segment,每个 Segment 文件的大小相等。
- Broker
Kafka 集群包含一个或多个服务器,每个服务器节点称为一个 Broker。
Broker 存储 Topic 的数据。如果某 Topic 有 N 个 Partition,集群有 N 个 Broker,那么每个 Broker 存储该 Topic 的一个 Partition。
如果某 Topic 有 N 个 Partition,集群有(N+M)个 Broker,那么其中有 N 个 Broker 存储该 Topic 的一个 Partition,剩下的 M 个 Broker 不存储该 Topic 的 Partition 数据。
如果某 Topic 有 N 个 Partition,集群中 Broker 数目少于 N 个,那么一个 Broker 存储该 Topic 的一个或多个 Partition。
在实际生产环境中,尽量避免这种情况的发生,这种情况容易导致 Kafka 集群数据不均衡。
- Producer:生产者
即消息的发布者,生产者将数据发布到他们选择的主题。
生产者负责选择将哪个记录分配给主题中的哪个分区。即:生产者生产的一条消息,会被写入到某一个 Partition。
- Consumer:消费者
可以从 Broker 中读取消息。一个消费者可以消费多个 Topic 的消息;一个消费者可以消费同一个 Topic 中的多个 Partition 中的消息;一个 Partiton 允许多个 Consumer 同时消费。
- Consumer Group
Consumer Group 是 Kafka 提供的可扩展且具有容错性的消费者机制。组内可以有多个消费者,它们共享一个公共的 ID,即 Group ID。组内的所有消费者协调在一起来消费订阅主题 的所有分区。
Kafka 保证同一个 Consumer Group 中只有一个 Consumer 会消费某条消息。
实际上,Kafka 保证的是稳定状态下每一个 Consumer 实例只会消费某一个或多个特定的 Partition,而某个 Partition 的数据只会被某一个特定的 Consumer 实例所消费。
下面我们用官网的一张图, 来标识 Consumer 数量和 Partition 数量的对应关系。
- Replizcas of partition:分区副本
副本是一个分区的备份,是为了防止消息丢失而创建的分区的备份。
Kafka 为分区引入了多副本(Replica)机制,通过增加副本数量可以提升容灾能力。同一分区的不同副本中保存的是相同的消息(在同一时刻,副本之间并非完全一样),副本之间是"一主多从"的关系,其中 leader 副本负责处理读写请求,follower 副本只负责与 leader 副本的消息同步。当 leader 副本出现故障时,从 follower 副本中重新选举新的 leader 副本对外提供服务。
如上图所示,Kafka 集群中有4个 broker,某个主题中有3个分区,且副本因子(即副本个数)也为3,如此每个分区便有1个 leader 副本和2个 follower 副本。
- Partition Leader
每个 Partition 有多个副本,其中有且仅有一个作为 Leader,Leader 是当前负责消息读写 的 Partition。即所有读写操作只能发生于 Leader 分区上。
- Partition Follower
所有 Follower 都需要从 Leader 同步消息,Follower 与 Leader 始终保持消息同步。Leader 与 Follower 的关系是主备关系,而非主从关系。
- ISR
- ISR,In-Sync Replicas,是指副本同步列表。ISR 列表是由 Leader 负责维护。
- AR,Assigned Replicas,指某个 Partition 的所有副本, 即已分配的副本列表。
- OSR,Outof-Sync Replicas,即非同步的副本列表。
- AR=ISR+OSR
- Offset:偏移量
每条消息都有一个当前 Partition 下唯一的 64 字节的 Offset,它是相当于当前分区第一条消息的偏移量。
- Broker Controller
Kafka集群的多个 Broker 中,有一个会被选举 Controller,负责管理整个集群中 Partition 和 Replicas 的状态。
只有 Broker Controller 会向 Zookeeper 中注册 Watcher,其他 Broker 及分区无需注册。即 Zookeeper 仅需监听 Broker Controller 的状态变化即可
- HW 与 LEO
- HW,HighWatermark,高水位,表示 Consumer 可以消费到的最高 Partition 偏移量。HW 保证了 Kafka 集群中消息的一致性。确切地说,是保证了 Partition 的 Follower 与 Leader 间数 据的一致性。
- LEO,Log End Offset,日志最后消息的偏移量。消息是被写入到 Kafka 的日志文件中的, 这是当前最后一个写入的消息在 Partition 中的偏移量。
- 对于 Leader 新写入的消息,Consumer 是不能立刻消费的。Leader 会等待该消息被所有 ISR 中的 Partition Follower 同步后才会更新 HW,此时消息才能被 Consumer 消费。
下图说明了两者的关系
- ZooKeeper
ZooKeeper 负责维护和协调 Broker,负责 Broker Controller 的选举。在 Kafka 0.9 之前版本,Offset 是由 ZK 负责管理的。ZooKeeper 负责 Controller 的选举,Controller 负责 Leader 的选举。
- Coordinator
一般指的是运行在每个 Broker 上的 Group Coordinator 进程,用于管理 Consumer Group 中的各个成员,主要用于 Offset 位移管理和 Rebalance。一个 Coordinator 可以同时管理多个消费者组。
- Rebalance
当消费者组中的数量发生变化,或者 Topic 中的 Partition 数量发生了变化时,Partition 的所有权会在消费者间转移,即 Partition 会重新分配,这个过程称为再均衡 Rebalance。
再均衡能够给消费者组及 Broker 带来高性能、高可用性和伸缩,但在再均衡期间消费者是无法读取消息的,即整个 Broker 集群有小一段时间是不可用的。因此要避免不必要的再均衡。
- Offset Commit
Consumer 从 Broker 中取一批消息写入 Buffer 进行消费,在规定的时间内消费完消息后,会自动将其消费消息的 Offset 提交给 Broker,以记录下哪些消息是消费过的。当然,若在时限内没有消费完毕,其是不会提交 Offset 的。
RocketMQ的 4个核心组件 NameServer、Broker、Producer、Consumer以及组件间的交互逻辑,具体信息如下:
NameServer
NameServer是 RocketMQ中的注册中心,负责维护 Broker集群的路由信息,为了高可用,NameServer可以集群部署,需要特别注意:NameServer相互之间不会通信,它们是一种Peer to Peer
的对等关系,并且每个 NameServer都保存着所有 Broker的信息。
NameServer的核心功能包括路由注册、路由发现、路由更新等,具体描述如下:
当 Broker启动时会向所有的 NameServer注册自身的信息,比如 IP、端口、Topic、Queue等,NameServer会将这些信息存入本地数据表中。默认情况下,Broker每隔 30s会向 NameServer发送一次心跳包,NameServer接收到心跳包后更新 Broker状态,如果 NameServer在 120s内没有接收到心跳包,会认为 Broker异常,从而剔除该心跳异常的 Broker。
当存在 Producer和 Consumer时,它们默认会每隔 30s定时从 NameServer获取 Broker集群信息并更新本地缓存,然后对 Broker列表进行负载均衡,从而将消息发送给 Broker或者从 Broker获取消息。
Broker
Broker是 RocketMQ中的数据存储节点,负责接收、存储和转发消息。
Broker可以集群部署,每个集群下面可以有多个组(BrokerName一样),每个组还可以主从部署,BrokerId=0
代表主节点,BrokerId=1
代表从节点。
Broker
的主要职责包括消息接收、消息存储、消息转发、消息索引、负载均衡,具体描述如下:
当 Broker启动时会所有的 NameServer注册信息以及后期定时向 NameServer发送心跳包,当 Broker接收到 Producer发送的消息后,首先会将消息写入 CommitLog(Write ahead log,WAL),然后开启后台线程将 CommitLog上的数据索引写入 write queue,这样可以确保消息持久化到磁盘上。
另外,Broker 会根据消费者的消费模式(推模式或拉模式),主动推送消息或等待消费者拉取消息,为了提高消息的检索速度,Broker还会为消息创建索引,支持快速定位和检索消息。
Producer
Producer是 RocketMQ中的生产者,负责发送消息。
Producer 和 Broker 是通过 Topic这样一个虚拟的概念建立关系的,当创建 Topic后,其实已经建立了 Topic和 Broker的关系,而这个关系的桥梁就是 queue,在 Broker中,有 write queue 和 read queue两种类型。
Producer每隔 30s从 NameServer拉取所有的 Topic以及 Broker信息,当消息发送到 Topic之后,消息首先会被写入一个 CommitLog的日志文件中,然后有后台线程将消息在 CommitLog磁盘上的地址等索引信息写入 write queue。这样,一个 Topic的数据就可以存储在不同的 Broker上,真正达到了数据的分布式存储,即便有部分Broker异常,受影响的数据也局限在这些 Broker上。
Producer发送消息有 3种方式:同步发送、异步发送和单向发送 。
- 同步发送:Producer 发送消息后需要等待 Broker的确认,这种方式保证消息可靠地发送到 Broker,适用于对消息可靠性要求较高的场景,比如金融领域。
- 异步发送:Producer 发送消息后不等待 Broker的确认,而是通过回调函数处理发送结果,该方式可以提高系统的并发性和吞吐量,适用于日志收集,监控报警等场景。
- 单向发送:Producer 仅发送消息,不关心发送结果,也不等待 Broker的确认。这种方式的性能最高,但无法保证消息一定被发送成功,适用于数据采集,实时统计等场景。
消息重试:在消息发送失败时,Producer 可以进行重试,确保消息最终被成功发送。
Consumer
Consumer是 RocketMQ中的消费者,负责消费和处理消息,通常是真实的业务系统,Consumer的整个工作流程描述如下:
当 Consumer启动后,会向 Broker订阅感兴趣的 Topic,当 Topic中的 read queue有消息时,Consumer会定时拉取,然后执行真实的业务逻辑。当 Consumer成功处理消息后,需要向 Broker发送确认信息,Broker收到确认信息标记该消息已消费,避免重复消费,另外,为了防止丢消息,Consumer一般不建议多线程处理。Consumer可以通过顺序消费和并行消费等方式去拉取信息,从而满足不同的业务需求。
因为一个 Topic可以对应不同 Broker上的 read queue,因此,一个 Consumer可以消费不同 Broker上的数据。
消息队列服务, 通常会涉及三个概念:消息生产者(简称生产者),消息队列,消息消费者(简称消费者)。
RabbitMQ 在这个基础上, 多做了一层抽象--生产者和消息队列之间, 加入了交换机 (Exchange)。这样生产者和消息队列之间就没有直接联系了,转而变成生产者把消息发给交换机,交换机根据路由规则,将消息转发给指定消息队列,然后消费者从消息队列中获取消息。
基本概念
Producer
(消息生产者):向消息队列发布消息的客户端应用程序。Consumer
(消息消费者):从消息队列获取消息的客户端应用程序。Channel
(信道):多路复用连接中的一条独立的双向数据流通道。信道是建立在真实的TCP连接上的虚拟连接。RabbitMQ中,生产者通过和交换机建立信道来发送消息,同样消费者也需要通过和队列建立信道来获取消息。对于操作系统来说建立和销毁TCP连接都是比较昂贵的开销,所以引入了信道的概念,以复用一条TCP连接。也就说,一个TCP 被多个线程共享,每个线程对应一个信道,每个信道都有唯一的ID,保证了信道的私有性。Message
(消息):消息由消息头和消息体组成。消息体是不透明的,而消息头则由一系列的可选属性组成,这些属性包括RoutingKey
(路由键)、priority(消息优先权)、delivery-mode(是否持久性存储)等。Queue
(消息队列):存储消息的一种数据结构,用来保存消息,直到消息发送给消费者。它是消息的容器,也是消息的终点。一个消息可投入一个或多个队列。消息一直在队列里面,等待消费者将它取走。需要注意的,当多个消费者订阅同一个队列时,该队列中的消息会被平均分摊给多个消费者进行处理,而不是每个消费者都收到所有的消息并处理,即每一条消息只能被一个订阅者接收。RoutingKey
(路由键):消息头的一个属性,用于标记消息的路由规则,决定了交换机的转发路径。最大长度255 字节。如下图,当我们创建好交换机和队列后,需要使用路由键将两者进行绑定,所以路由键也叫绑定键(BindingKey
)。当消息生产者向交换机发送消息时,必须指定一个路由键,当交换机收到这条消息之后,会解析并获取路由键,然后同交换机和队列的绑定规则,并将消息分发到符合规则的队列中。
路由键是一个点分字符串,比如``task,
quick.orange,
quick.orange.rabbit`,被点号". "分隔开的每一段独立的字符串称为一个单词
1.4.3 面试前准备建议和思路
如果让你写一个消息队列,该如何进行架构设计?说下你的思路
这种问题,说白了,起码不求你看过那些技术的源码,但是你应该大概知道那些技术的基本原理,核心组成部分,基本架构个构成,然后参照一些开源技术把一个系统设计出来的思路说一下就好了。
- 掌握消息队列核心概念
- 消息模型:点对点 vs 发布订阅
- 消费模式:Push vs Pull
- 持久化机制:文件存储 vs 数据库
- 消息协议:AMQP、MQTT、STOMP等
- 熟悉主流消息队列实现
- Kafka:高吞吐、分区、ISR机制
- RocketMQ:事务消息、延迟消息
- RabbitMQ:Exchange路由、ACK机制
- Pulsar:分层存储、多租户
- 准备设计案例
- 设计一个支持百万QPS的消息队列
- 如何实现跨地域消息同步
- 消息堆积处理方案
1.4.4 📌面试必过的回答思路
🔔
"实现一个生产级消息队列需要综合考虑多个方面,我会从以下几个核心维度进行设计:
1. 消息可靠性保障
- 持久化机制:采用顺序写+页缓存技术提升写入性能,同时通过刷盘策略确保数据持久化
- 副本机制:基于Raft/Paxos实现多副本同步,确保单点故障时不丢数据
- ACK确认:实现生产者确认和消费者确认双重保障
- 事务支持:提供类似RocketMQ的事务消息机制保证业务一致性
2. 消息顺序性处理 - 分区设计:通过消息Key哈希保证同一业务的消息路由到同一分区
- 单线程消费:每个分区由单个消费者线程处理,避免并发乱序
- 顺序写保证:存储层采用顺序追加写,杜绝文件写入乱序
3. 高可用架构 - 集群部署:无单点设计,NameServer/Broker都采用集群部署
- 故障转移:实现自动化的Leader选举和故障检测转移
- 流量控制:具备智能限流能力,防止雪崩效应
4. 高性能设计 - 零拷贝:使用sendfile等技术减少内核态拷贝
- 批量处理:支持生产者和消费者的批量操作
- 内存映射:采用MMAP提升IO性能
- 异步刷盘:平衡性能与可靠性
5. 扩展性考虑 - 分区自动再平衡:消费者增减时自动调整分区分配
- 水平扩展:支持无缝添加Broker节点扩展容量
- 分层存储:热数据内存缓存,冷数据归档存储
6. 运维支持 - 完善监控:消息堆积、消费延迟等关键指标监控
- 可视化控制台:提供管理界面查看队列状态
- 死信队列:处理无法正常消费的消息
- 轨迹追踪:支持消息全链路追踪
在实际项目中,我曾主导设计过一个日均百亿级消息的系统。我们采用类似Kafka的分区设计,但针对业务特点优化了延迟消息的实现,通过时间轮算法将延迟消息的精度控制在秒级,同时保证了99.99%的可靠性。"
1.5 Redis的zset实现排行榜,实现分数相同按照时间顺序排序,怎么做?
1.5.1 面试考察重点
- 面试官的考察目标
- Redis高级特性应用:能否灵活运用ZSET的特性解决实际问题
- 数据编码设计:对复合排序条件的处理能力
- 性能优化意识:在保证功能的前提下考虑性能影响
- 工程实现细节:对具体实现方案的完整性和可行性评估
- 关键关注点
- 分数设计:如何将分数和时间编码到ZSET的score中
- 排序保证:确保Redis能正确按照复合条件排序
- 数据解析:从存储的数据中正确解析原始信息
- 性能影响:方案对内存和计算的影响
- 扩展性:方案对其他排序需求的适应性
1.5.2 面试核心知识点详解
三种实践方案
1.5.2.1 ① 方案一:通过时间差计算出方案; 分数 + 结束时间戳(固定位数)
注意这里只是演示:并没有真正先拿到值再处理以后覆盖原值,详细请看到最后面
package.path = package.path..";~/redis-lua/src/?.lua" --redis.lua所在目录
local json_encode = require "cjson" .encode
local redis = require("redis")
local reds, err = redis.connect('127.0.0.1',6379)
local key = string.format("dragon:boat:festival:date:%s", os.date('%Y%m%d')) -- 排行榜key
local endtime = 1623600000 -- 活动结束时间
local value = 9999 -- 用户增加分数值
local sufix = endtime - os.time() -- 用户更新分数时间距离结束时间的差值
local user_id = 1001 -- 用户id也就是有序集合的 field
local diff = 5 - string.len(sufix) -- 计算时间戳差值长度是否小于5 因为是每天排行榜 所以最大的时间差为 86400
if diff < 5 then
for i=1,diff do
sufix = 0 .. sufix
end
end
local score = value .. sufix -- 组合成新的值
local res, err = reds:zadd(key, score, user_id)
print(sufix)
print(res)
print(key)
从上面大家可以看到整体的结构思路,主要是拿到分数更新是距离结束的差值,然后再和分数合并一起;分数在前,时间差在后;拿到集合里面的分数之后可以通过:截图固定位数拿到分数。上面结果集如下:
➜ ~ lua hello.lua
dragon:boat:festival:date:20210613
04403
可能大家会有疑问:获取score值是先取出来,处理成new_score后再存进去,还是会产生并发问题吧?这个问题非常的不错:以上操作并不是原子操作,并发情况下会导致我们socre不准确,文章结尾会对这个做处理。
1.5.2.2 ② 方案二:通过进行位操作; 原理是 分数 + 结束时间戳(固定位数)
首先:需要拿活动结束时间作为约束 endtime
其次:对分数 score 先进行左移 27位再加上时间戳差值;同积分情况,越往后添加的,经过计算之后 new_score 越小;实现原理 new_score = score << 27 + (endtime - os.time())
然后:我们可以通过 对从集合中拿到的 分数 进行 右移 27位;则会被去掉时间戳差值,显示真正的积分
代码实现如下:
local redis = require("redis")
--- 获取集合中真实分数
--- @param score number 集合中用户分数
function get_score(score)
return math.floor(score / (2 ^ 27))
end
--- 计算位运算以后的分数
--- @param point number 增加的分数
--- @param timestamp number 活动结束时间
function to_score(point, timestamp)
point = tonumber(point) or 0
timestamp = tonumber(timestamp) or 0
local score = point * (2 ^ 27)+ (timestamp - os.time())
return score
end
local endtime = 1623686400 -- 活动结束时间
local new_score = to_score(999, endtime)
local res, err = reds:zadd(key, new_score, user_id)
print(new_score)
print(get_score(new_score))
执行结果:
134083555799
999
观察问题:
虽然我们通过位左右移动操作在加上时间戳差值,尽管集合同分数相同下的概率变小了。但不知道聪明的你有没有发现,这里计算的是 秒时间戳,存在相同时间到达相同分数的情况,依然会出现集合按照元素的 字典排序 对于要求不是很高,可以完全忽略这种出现的概率。
如果是对结果集超严谨:我们可以对时间戳精确到 毫秒 或者 微秒
## 要注意的是:
分数 = 等级 + 时间差 (当前系统时间戳)
分数是 64位的长整型 Long (有符号)
保证我们组合的位数在规定方位内即可
1.5.2.3 ③ 方案三:基于雪花算法思想实现
## snowflake的结构如下
0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000
① 第一位为未使用
② 后面41位为毫秒级时间(41位的长度可以使用69年)
③ 接着 5位datacenterId 和 5位workerId (10位的长度最多支持部署1024个节点)
④ 最后12位是毫秒内的计数(12位的计数顺序号支持每个节点每毫秒产生4096个ID序号)
⑤ 一共加起来刚好64位,为一个Long型。(转换成字符串长度为18)
为什么说是用到了雪花算法的思想呢?这是因为项目里面使用lua实现学法算法并加以修改,所以再review代码时,我优先想到的就是利用雪花算法思想来实现排行榜。当然这个跟方案二还是有相同之处,只是为了提供多种方式来实现。
排行榜实现原理
1位高位不用 + 41位时间戳 + 22位表示积分
那是不是就可以表示成这样
0(最高位保留) | 000000000000 000000 000000(22位分数位) | 0 0000000000 0000000000 0000000000 0000000000(41位时间戳)
分数在高位,时间戳在低位,这样就可以保证不管时间戳是多少,分数越大,那么值就越大,也就符合我们需求
22位是符合我们业务需求的值:最大支持 (2^21-1)
41位时间戳最大支持毫秒级:2^40-1
优秀的你估计又想到了,22位不满足我们业务需求怎么办,值太小了?那么我们也是可以通过对41位时间戳进行 压缩 ,我们可以像方案一二方法一样对时间戳进行压缩:活动结束时间 - 当前时间戳,完全可以从64位压缩32位或者16位,这些都是可以的。下面我通过代码实现:
local time = os.time
local bits = {}
--- 对每一位进行位运算,然后将值返回
local function _bits_op(left, right, func)
if left < right then
left, right = right, left
end
local result = 0
local shift = 1
while left ~= 0 do
local num_1 = left % 2 -- 取余 取得每一位(最右边)
local num_2 = right % 2 -- 取余
local ok, ret = pcall(func, num_1, num_2)
ret = tonumber(ret) or 0
result = shift * ret + result
shift = shift * 2
left = math.modf(left / 2) -- 右移
right = math.modf(right / 2) -- 右移
end
return result
end
--- 或操作
function bits.bits_bor(left, right)
return _bits_op(left, right, function(left1, right1)
return (left1 == 1 or right1 == 1) and 1 or 0
end)
end
--- 右移
function bits.bits_rshift(left, num)
return math.floor(left / (2 ^ num))
end
--- 左移
function bits.bits_lshift(left, num)
return left * (2 ^ num)
end
--- 获取分数
function bits.get_score(score, bitnum)
score = tonumber(score) or 0
local bit_num = tonumber(bitnum) or 41
return bits.bits_rshift(score, bit_num)
end
--- 更新分数
function bits.to_score(point, curtime, bitnum)
local points = tonumber(point) or 0
local timestamp = tonumber(curtime) or 0
local bit_num = tonumber(bitnum) or 41
local score = 0
score = bits.bits_bor(score, points)
score = bits.bits_lshift(score, bit_num)
score = bits.bits_bor(score, (timestamp - time()))
return score
end
local score = 0 -- 当前分数
local cur_ponit = bits.get_score(score)
print(cur_ponit)
local new_score = bits.to_score(cur_ponit + 99999, 1625068800)
local res, err = reds:zadd(key, new_score, user_id)
print(new_score)
print(bits.get_score(new_score))
生成结果:
0 -- 初始化真实分数
219900126533365579 -- 进行位运算操作
99999 -- 还原真实分数
左移和右移刚工作时,可能面试题大都会有这些。通俗的说:位移是将数据转为二进制后,进行左右移动;如果向左移动,则右边补零;如果是向右移动,则左边补零,溢出的则删掉。所以更简单的理解为:向左移动为整数的乘法;向右移动为整数的取整除法。这样大家就可以更好理解上面案例代码
左移(<<):将第一个数向左移动第二个数指定的位数,空出的位置 补零
## 注意
左移相当于乘法;左移一位相当于乘以 2
## 例如
a << 1 = a * 2 => a * 2^num
右移(>>):将第一个数向右移动第二个数指定的位数,空出的位置 补零
## 注意
右移相当于整除;右移一位相当于除以 2
## 例如
a >> 1 = a / 2 => a / 2^num
如何保证原子操作
并发情况下redis-lua保证原子操作这篇文章已经很请出的写明并发情况下保证操作原子性,这点很考验一个 高级开发工程师的基础,要知道现在不管大的或小的公司,在用户量和数据量较大的业务场景下很容易出现并发场景,所以大家一定要了解且会运用。
package.path = package.path..";~/redis-lua/src/?.lua" --redis.lua所在目录
local json_encode = require "cjson" .encode
local redis = require("redis")
local reds, err = redis.connect('127.0.0.1',6379)
-- 更新用户分数值
local _update_score = [[
local key = KEYS[1]
local field = ARGV[1]
local score = ARGV[2]
local timestamp = ARGV[3]
score = tonumber(score) or 0 -- 需要增加的用户分数值
timestamp = tonumber(timestamp) or 0 -- 时间差值
local cur_score = redis.call('zscore', key, field) -- 获取用户当前分数值
cur_score = tonumber(cur_score) or 0
local ponit = math.floor(cur_score / (2 ^ 27)) -- 右移获取用户真实分数值 去掉时间差值以后
ponit = tonumber(ponit) or 0
local ret = {"score", 0 ,"res", 0} -- 定义返回table
local num = ponit + score -- 用户增加后的分数值
local res = num * (2 ^ 27) + timestamp -- 经过左移处理后的分数值
redis.call('zadd', key, res, field) -- 塞进集合
ret[2] = num
ret[4] = res
return ret
]]
local key = string.format("dragon:boat:festival:date:%s", os.date('%Y%m%d')) -- 排行榜key
local endtime = 1623600000 -- 活动结束时间
local score = 9999 -- 用户增加分数值
local sufix = endtime - os.time() -- 用户更新分数时间距离结束时间的差值
local user_id = 1001 -- 用户id也就是有序集合的 field
local res , err = reds:eval(_update_score, 1, key, user_id, score, sufix)
print(json_encode(res))
结果集:
dragon:boat:festival:date:20210614
["score",19997,"res",2683951855961]
127.0.0.1:6379> ZSCORE dragon:boat:festival:date:20210614 1001
"2683951855961"
当然建议大家可以使用 evalsha 函数来实现,可以将脚本放进缓存,不需要每次都重新将脚本加载一次,减少网络开销和响应时长,保证我们业务能快速响应。
总结
上面三种方案是我根据同事提交的代码做了不同的方案处理,尽量保证业务简单化,不然本来十几行代码能解决的事情,却要弄出上百行代码实现。但是最终我们是要实现按照redis以外的规则对有序集合做排序处理。只是我们在实现功能时不要一味的有这种现象:"能实现就行,不管过程如何!",这种想法是不对的,你只有不断的去优化你的代码,才能提高自己的编码水平,让人看着赏心悦目。这是对自己负责
重点一定要保证并发情况下的原子操作!
-
向不同榜单中添加数据
/** * 更新zset的分数 * * @param type 榜单类型 1-小时榜 2-日榜 3-周榜 4-月榜 5-年榜 * @param key key * @param userId 用户id * @param score 增加的分数 */ public static void updateScore(int type, String key, long userId, double score) { double timestamp = 1 - (System.currentTimeMillis() / 1e13); //查询当前用户在该榜单中的分数 Double currentScore = redisTemplate.opsForZSet().score(key, userId); if (ObjectUtil.isNull(currentScore)) { boolean hasKey = redisTemplate.hasKey(key); //如果不存在排行榜中,要添加到排行榜 redisTemplate.opsForZSet().add(key, userId, score + timestamp); if(!hasKey){ //如果之前没有这个榜单,需要给当前榜单设置过期时间 LocalDateTime startTime = LocalDateTime.now(); LocalDateTime endTime = null; switch (type) { case 2: //2-日榜 endTime = startTime.withHour(23).withMinute(59).withSecond(59); break; case 3: //3-周榜 endTime = startTime.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)).withHour(23).withMinute(59).withSecond(59); break; case 4: //4-月榜 endTime = startTime.with(TemporalAdjusters.lastDayOfMonth()).withHour(23).withMinute(59).withSecond(59); break; case 5: //5-年榜 endTime = startTime.with(TemporalAdjusters.lastDayOfYear()).withHour(23).withMinute(59).withSecond(59); break; default: //1-小时榜 endTime = LocalDateTime.now().withMinute(59).withSecond(59); } redisTemplate.expire(key, Duration.between(startTime, endTime).getSeconds(), TimeUnit.SECONDS); } }else { //拿到原有的实际分数 Long originScore = getScoreAndRankByUserId(key, userId).getLong("score"); //更新到排行榜 redisTemplate.opsForZSet().add(key, userId, originScore + score + timestamp); } }
-
获取zset排行榜前 N 名
/** * 获取zset排行榜前 N 名。 * * @param n 排名数量 * @return 排行榜数据 */ public static Set<ZSetOperations.TypedTuple<Object>> getTopN(String key, int n) { Set<ZSetOperations.TypedTuple<Object>> typedTuples = redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, n - 1); return typedTuples; }
-
获取zset某个用户的分数和名次
/** * 获取zset某个用户的分数和名次 * * @param key key * @param userId 用户id * @return */ public static JSONObject getScoreAndRankByUserId(String key, long userId) { JSONObject result = JSONUtil.createObj(); Double score = redisTemplate.opsForZSet().score(key, userId); if (ObjectUtil.isNull(score)) { return result.putOpt("score", 0).putOpt("rank", 0); } // 如果玩家已经在排行榜中 double rank = redisTemplate.opsForZSet().reverseRank(key, userId) + 1; return result.putOpt("score", Convert.toLong(score)).putOpt("rank", rank); }
-
根据名次获取zset某个用户的分数
/** * 根据名次获取zset某个用户的分数 * * @param key key * @param rank 名次 * @return */ public static Long getScoreByRank(String key, int rank) { // 获取指定名次的用户ID Object userId = redisTemplate.opsForZSet().reverseRange(key, rank, rank).stream().findFirst().orElse(null); if (userId == null) { // 如果没有找到对应的用户 return 0l; } // 获取用户的分数 return getScoreAndRankByUserId(key, Convert.toLong(userId)).getLong("score"); }
1.5.3 面试前准备建议和思路
- 深入理解ZSET特性
- 掌握ZSET的底层实现(跳表+哈希表)
- 了解ZSET的score是64位浮点数(double)
- 熟悉ZADD、ZRANGE等常用命令
- 准备分数编码方案
- 研究将时间戳融入score的方法
- 准备不同精度下的处理方案
- 考虑大整数运算的溢出问题
- 准备实际案例
- 设计一个百万用户级别的排行榜
- 处理极端情况(如分数溢出)
1.5.4 📌面试必过的回答思路
- 标准回答结构
问题分析:明确需求和技术挑战
核心方案:提出分数编码设计方案
实现细节:具体命令和数据结构
边界处理:异常情况考虑
优化建议:性能和使用建议
- 完整回答示例
"要实现Redis ZSET排行榜在同分情况下按时间排序,关键在于巧妙设计score的编码方式。
我的方案如下:
- 分数编码设计
Redis ZSET的score是64位浮点数,我们可以将原始分数和时间戳组合成一个复合score:
复合score = 原始分数 + (1 - 时间戳标准化值)
其中时间戳标准化值 = 时间戳 / 最大时间戳(如当前时间+10年)
具体实现步骤:
-
数据准备阶段:
- 获取当前时间戳(精确到毫秒):timestamp = System.currentTimeMillis()
- 定义最大时间戳(如当前时间+10年):maxTimestamp = timestamp + 10365 246060*1000
- 计算标准化时间值:normalizedTime = timestamp / maxTimestamp
- 计算复合score:combinedScore = originalScore + (1 - normalizedTime)
-
Redis操作阶段:
添加成员
ZADD leaderboard combinedScore memberId
查询前10名
ZREVRANGE leaderboard 0 9 WITHSCORES
-
数据解析阶段:
从复合score中提取原始分数:originalScore = Math.floor(combinedScore)
计算时间部分:timePart = 1 - (combinedScore - originalScore)
还原时间戳:timestamp = timePart * maxTimestamp -
方案优势分析
-
精确排序:保证同分情况下后提交的排名更低(时间越大标准化值越大,1-normalizedTime越小)
-
无精度损失:利用64位浮点数的52位尾数精度,足够存储毫秒级时间戳
-
性能无损:相比单独存储时间戳的方案,不需要额外的内存开销
-
边界情况处理
分数溢出:当原始分数非常大时(> 2^52),浮点数精度可能不够,这时可以:
- 降低时间精度(秒级代替毫秒)
- 使用分数缩放(如原始分数/1000)
时间反转:如果需要先提交的排名更低,可以使用normalizedTime直接相加
- 优化建议
- 对于时间精度要求不高的场景,可以使用秒级时间戳减少计算量
- 可以封装工具类处理score的编码解码,避免业务代码重复计算
- 对于超大规模排行榜,考虑分片存储
在实际项目中,我们使用这种方案实现了游戏实时排行榜,支持每天千万级的更新操作,性能表现优异。相比单独使用时间戳作为二级排序的方案,内存使用减少了40%,查询性能提升了30%。"
3. 回答加分项
- 提供具体的编码公式和计算示例
- 讨论不同时间精度对方案的影响
- 对比其他实现方案的优劣
- 分享实际项目中的性能数据
- 提出扩展性思考(如支持更多排序维度)
1.6 消息队列使用拉模式好还是推模式好?为什么?
1.6.1 面试考察重点
- 面试官的考察目标
- 消息队列原理理解:对推拉模式本质差异的掌握程度
- 架构权衡能力:在不同场景下的技术选型能力
- 性能优化意识:对系统资源利用的考量
- 实际工程经验:是否有真实场景的实践经验
- 关键关注点
- 控制权归属:消费节奏由服务端还是客户端控制
- 实时性对比:两种模式在消息延迟方面的表现
- 资源消耗:对服务端和客户端资源的占用情况
- 适用场景:不同业务场景下的最佳选择
- 主流实现:各消息队列中间件的实际实现方式
1.6.2 面试核心知识点详解
首先明确一下推拉模式到底是在讨论消息队列的哪一个步骤,一般而言我们在谈论推拉模式的时候指的是 Comsumer 和 Broker 之间的交互。
默认的认为 Producer 与 Broker 之间就是推的方式,即 Producer 将消息推送给 Broker,而不是 Broker 主动去拉取消息。
想象一下,如果需要 Broker 去拉取消息,那么 Producer 就必须在本地通过日志的形式保存消息来等待 Broker 的拉取,如果有很多生产者的话,那么消息的可靠性不仅仅靠 Broker 自身,还需要靠成百上千的 Producer。
Broker 还能靠多副本等机制来保证消息的存储可靠,而成百上千的 Producer 可靠性就有点难办了,所以默认的 Producer 都是推消息给 Broker。
所以说有些情况分布式好,而有些时候还是集中管理好。
1.6.2.1 拉模式
拉模式指的是 Consumer 主动向 Broker 请求拉取消息,即 Broker 被动的发送消息给 Consumer。
我们来想一下拉模式有什么好处?
拉模式主动权就在消费者身上了,消费者可以根据自身的情况来发起拉取消息的请求。假设当前消费者觉得自己消费不过来了,它可以根据一定的策略停止拉取,或者间隔拉取都行。
拉模式下 Broker 就相对轻松了,它只管存生产者发来的消息,至于消费的时候自然由消费者主动发起,来一个请求就给它消息呗,从哪开始拿消息,拿多少消费者都告诉它,它就是一个没有感情的工具人,消费者要是没来取也不关它的事。
拉模式可以更合适的进行消息的批量发送,基于推模式可以来一个消息就推送,也可以缓存一些消息之后再推送,但是推送的时候其实不知道消费者到底能不能一次性处理这么多消息。而拉模式就更加合理,它可以参考消费者请求的信息来决定缓存多少消息之后批量发送。
拉模式有什么缺点?
消息延迟,毕竟是消费者去拉取消息,但是消费者怎么知道消息到了呢?所以它只能不断地拉取,但是又不能很频繁地请求,太频繁了就变成消费者在攻击 Broker 了。因此需要降低请求的频率,比如隔个 2 秒请求一次,你看着消息就很有可能延迟 2 秒了。
消息忙请求,忙请求就是比如消息隔了几个小时才有,那么在几个小时之内消费者的请求都是无效的,在做无用功。
1.6.2.2 推模式
推模式指的是消息从 Broker 推向 Consumer,即 Consumer 被动的接收消息,由 Broker 来主导消息的发送。
我们来想一下推模式有什么好处?
消息实时性高, Broker 接受完消息之后可以立马推送给 Consumer。
对于消费者使用来说更简单,简单啊就等着,反正有消息来了就会推过来。
推模式有什么缺点?
推送速率难以适应消费速率,推模式的目标就是以最快的速度推送消息,当生产者往 Broker 发送消息的速率大于消费者消费消息的速率时,随着时间的增长消费者那边可能就"爆仓"了,因为根本消费不过来啊。当推送速率过快就像 DDos 攻击一样消费者就傻了。
并且不同的消费者的消费速率还不一样,身为 Broker 很难平衡每个消费者的推送速率,如果要实现自适应的推送速率那就需要在推送的时候消费者告诉 Broker ,我不行了你推慢点吧,然后 Broker 需要维护每个消费者的状态进行推送速率的变更。
这其实就增加了 Broker 自身的复杂度。
所以说推模式难以根据消费者的状态控制推送速率,适用于消息量不大、消费能力强要求实时性高的情况下。
1.6.2.3 那到底是推还是拉
可以看到推模式和拉模式各有优缺点,到底该如何选择呢?
RocketMQ 和 Kafka 都选择了拉模式,当然业界也有基于推模式的消息队列如 ActiveMQ。
我个人觉得拉模式更加的合适,因为现在的消息队列都有持久化消息的需求,也就是说本身它就有个存储功能,它的使命就是接受消息,保存好消息使得消费者可以消费消息即可。
而消费者各种各样,身为 Broker 不应该有依赖于消费者的倾向,我已经为你保存好消息了,你要就来拿好了。
虽说一般而言 Broker 不会成为瓶颈,因为消费端有业务消耗比较慢,但是 Broker 毕竟是一个中心点,能轻量就尽量轻量。
那么竟然 RocketMQ 和 Kafka 都选择了拉模式,它们就不怕拉模式的缺点么? 怕,所以它们操作了一波,减轻了拉模式的缺点。
1.6.2.4 长轮询
RocketMQ 和 Kafka 都是利用"长轮询"来实现拉模式,我们就来看看它们是如何操作的。
为了简单化,下面我把消息不满足本次拉取的条数啊、总大小啊等等都统一描述成还没有消息,反正都是不满足条件。
RocketMQ 中的 PushConsumer 其实是披着拉模式的方法,只是看起来像推模式而已。
因为 RocketMQ 在被背后偷偷的帮我们去 Broker 请求数据了。
后台会有个 RebalanceService 线程,这个线程会根据 topic 的队列数量和当前消费组的消费者个数做负载均衡,每个队列产生的 pullRequest 放入阻塞队列 pullRequestQueue 中。然后又有个 PullMessageService 线程不断的从阻塞队列 pullRequestQueue 中获取 pullRequest,然后通过网络请求 broker,这样实现的准实时拉取消息。
这一部分代码我不截了,就是这么个事儿,稍后会用图来展示。
然后 Broker 的 PullMessageProcessor 里面的 processRequest 方法是用来处理拉消息请求的,有消息就直接返回,如果没有消息怎么办呢?我们来看一下代码。
而 PullRequestHoldService 这个线程会每 5 秒从 pullRequestTable 取PullRequest请求,然后看看待拉取消息请求的偏移量是否小于当前消费队列最大偏移量,如果条件成立则说明有新消息了,则会调用 notifyMessageArriving ,最终调用 PullMessageProcessor 的 executeRequestWhenWakeup() 方法重新尝试处理这个消息的请求,也就是再来一次,整个长轮询的时间默认 30 秒。
简单的说就是 5 秒会检查一次消息时候到了,如果到了则调用 processRequest 再处理一次。这好像不太实时啊? 5秒?
别急,还有个 ReputMessageService 线程,这个线程用来不断地从 commitLog 中解析数据并分发请求,构建出 ConsumeQueue 和 IndexFile 两种类型的数据,并且也会有唤醒请求的操作,来弥补每 5s 一次这么慢的延迟
代码我就不截了,就是消息写入并且会调用 pullRequestHoldService#notifyMessageArriving。
最后我再来画个图,描述一下整个流程。
1.6.3 面试前准备建议和思路
- 掌握核心概念差异
|----------------|-----------------------|-----------------------|
| 维度 | 推模式(Push) | 拉模式(Pull) |
| 控制方 | 服务端主导 | 消费者主导 |
| 实时性 | 高(消息即时推送) | 依赖轮询间隔 |
| 资源消耗 | 服务端压力大 | 消费者压力大 |
| 负载均衡 | 服务端实现复杂 | 消费者自主调节 |
| 实现复杂度 | 需维护每个消费者的状态 | 服务端无状态 |
- 研究主流消息队列实现
- Kafka:纯Pull模式,消费者主动拉取
- RocketMQ:长轮询(Pull+Push混合)
- RabbitMQ:基本Push模式,支持QoS控制
- Pulsar:Push模式为主,支持速率限制
- 准备实际场景案例
-
电商秒杀场景适合哪种模式
-
物联网设备数据采集如何选择
-
金融交易系统的最佳实践
-- 不合理的表结构
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(255),
age INT,
address VARCHAR(255),
city VARCHAR(255),
province VARCHAR(255)
);-- 优化后,遵循第三范式
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(255),
age INT,
address_id INT,
FOREIGN KEY (address_id) REFERENCES addresses(id)
);CREATE TABLE addresses (
id INT PRIMARY KEY,
city VARCHAR(255),
province VARCHAR(255)
);
1.9.2.8 数据库IO或者CPU比较高?
问题分析: 数据库IO或CPU过高会导致数据库响应变慢,影响应用性能。
解决方案:
- • 使用数据库监控工具: 例如,MySQL 可以使用
SHOW PROCESSLIST
命令查看当前正在执行的SQL语句,使用SHOW STATUS
命令查看数据库状态信息。 - • 分析慢查询日志: 慢查询日志记录了执行时间超过指定阈值的SQL语句,可以帮助我们找出执行效率低的SQL语句。
- • 优化数据库配置参数: 例如,调整内存、连接数等参数,可以提高数据库性能。
案例:
-- 查看MySQL当前正在执行的SQL语句
SHOW PROCESSLIST;
-- 查看MySQL状态信息
SHOW STATUS LIKE 'Threads_connected';
SHOW STATUS LIKE 'Innodb_buffer_pool_reads';
1.9.2.9 数据库参数不合理?
问题分析: 数据库参数的设置对数据库性能有很大影响。
解决方案:
- • 根据数据库类型和硬件配置,调整内存、连接数等参数: 例如,MySQL 的
innodb_buffer_pool_size
参数用于设置 InnoDB 存储引擎的缓冲池大小,可以根据服务器的内存大小进行调整。 - • 参考官方文档和最佳实践,设置合理的参数值: 例如,MySQL 官方文档提供了不同场景下的参数配置建议。
- • 使用数据库性能测试工具,验证参数调整效果: 例如,可以使用
sysbench
工具对数据库进行压力测试,评估参数调整后的性能提升。
案例:
-- 修改MySQL InnoDB缓冲池大小
SET GLOBAL innodb_buffer_pool_size = 1G;
1.9.2.10 事务比较长?
问题分析: 长事务会占用数据库资源,影响其他事务的执行。
解决方案:
- • 尽量缩短事务执行时间: 例如,将耗时的操作移到事务外执行。
- • 将大事务拆分为多个小事务: 例如,将批量插入操作拆分为多个小批量插入操作。
- • 避免在事务中进行耗时操作: 例如,避免在事务中进行网络请求、文件操作等。
案例:
-- 长事务
START TRANSACTION;
-- 执行耗时操作
UPDATE users SET balance = balance - 100 WHERE id = 1;
-- 执行耗时操作
UPDATE orders SET status = 'paid' WHERE user_id = 1;
COMMIT;
-- 优化后,将事务拆分为两个小事务
START TRANSACTION;
UPDATE users SET balance = balance - 100 WHERE id = 1;
COMMIT;
START TRANSACTION;
UPDATE orders SET status = 'paid' WHERE user_id = 1;
COMMIT;
1.9.2.11 锁竞争导致的等待?
问题分析: 锁竞争会导致事务等待,影响数据库并发性能。
解决方案:
- • 使用乐观锁机制: 乐观锁假设并发冲突的概率较低,在提交事务时才会检查数据是否被修改,可以减少锁冲突。
- • 合理设置事务隔离级别: 例如,将事务隔离级别设置为
READ COMMITTED
,可以避免脏读,同时提高并发性能。 - • 优化SQL语句: 例如,避免使用
SELECT ... FOR UPDATE
语句,可以减少锁的持有时间。
案例:
-- 使用乐观锁
UPDATE products SET stock = stock - 1, version = version + 1 WHERE id = 1 AND version = 1;
-- 如果更新失败,说明数据已被修改,需要重新读取数据并重试
1.9.3 面试前准备建议和思路
1. 掌握核心调优工具
- 执行计划分析 :
- MySQL的EXPLAIN/EXPLAIN ANALYZE
- Oracle的EXPLAIN PLAN FOR
- SQL Server的SHOWPLAN
- 性能监控工具 :
- MySQL慢查询日志
- performance_schema
- sys schema
2. 研究常见优化场景
- 大表关联查询优化
- 分页查询优化
- 子查询优化
- 大批量数据操作优化
3. 准备典型案例
- 索引失效场景及解决方案
- 从10s优化到10ms的案例
- 不同数据库的调优差异
1.9.4 📌面试必过的回答思路
1. 标准回答结构
- 调优方法论:系统化的调优流程
- 诊断手段:如何定位性能问题
- 优化技巧:具体的优化手段
- 验证方法:如何验证优化效果
- 经验分享:实战案例与心得
2. 完整回答示例
"我采用系统化的五步法进行SQL调优,确保全面覆盖所有优化可能性:
1. 问题诊断与基准测试
- 捕获问题SQL:通过慢查询日志或监控系统
- 建立性能基线:记录当前执行时间、资源消耗
- 使用EXPLAIN分析:重点关注type列(ALL->index->range->ref->eq_ref->const)和Extra列
- 监控实时指标:CPU、IOPS、锁等待、临时表等
2. 执行计划分析与优化
- 索引优化 :
- 检查是否走错索引:force index测试不同索引效果
- 避免索引失效:如对索引列使用函数、隐式转换
- 创建复合索引:遵循最左前缀原则
- 覆盖索引优化:避免回表操作
- 查询重写 :
- 拆分复杂查询:将大查询拆分为多个小查询
- 优化关联顺序:小表驱动大表
- 子查询优化:转写为JOIN
- 避免SELECT *:只查询必要字段
3. 数据库配置调优
- 关键参数调整:
- 缓冲池大小(innodb_buffer_pool_size)
- 排序缓冲区(sort_buffer_size)
- 连接线程数(thread_pool_size)
- 统计信息更新:
- ANALYZE TABLE更新统计信息
- 调整采样率提高准确性
4. 架构级优化
- 读写分离:将报表类查询路由到只读实例
- 分库分表:水平拆分大表
- 缓存策略:热点数据使用Redis缓存
- 数据归档:冷热数据分离存储
5. 验证与监控
- A/B测试:对比优化前后性能
- 执行计划对比:确认优化效果
- 建立监控:持续跟踪SQL性能
- 压测验证:模拟高峰场景
实战案例分享:
在电商系统中优化过一个商品搜索接口,原始SQL执行需要8秒:
SELECT * FROM products
WHERE category_id = 5
AND price BETWEEN 100 AND 500
ORDER BY create_time DESC
LIMIT 1000,20;
优化措施:
- 创建复合索引:(category_id, price, create_time)
- 改写分页逻辑:使用游标分页替代LIMIT OFFSET
- 只查询必要字段
- 添加二级缓存
优化后SQL执行时间降至50ms,QPS从10提升到200。关键是通过EXPLAIN发现原始查询进行了全表扫描,且使用了filesort临时表排序。"
3. 回答加分项
- 展示EXPLAIN执行计划的分析过程
- 提供具体的性能对比数据
- 讨论不同数据库的优化差异
- 分享调优工具的使用技巧
- 强调预防优于治疗的设计理念
1.10 不使用synchronized和Lock如何设计一个线程安全的单例?
1.10.1 面试考察重点
1. 面试官的考察目标
- 并发编程深度:对Java内存模型和并发机制的掌握
- 设计模式理解:单例模式的各种实现方式
- 创新思维:突破常规解决方案的能力
- JVM知识:对类加载机制的理解
2. 关键关注点
- 线程安全性:如何保证在多线程环境下正确工作
- 性能考量:实现方案对性能的影响
- 懒加载:是否支持延迟初始化
- 反射安全:是否能防止反射攻击
- 序列化安全:反序列化时是否保持单例
1.10.2 面试核心知识点详解
1、饿汉式:
利用静态代码只执行一次实例化一个对象。
jdk中的类Runtime就是典型的饿汉式单例模式,该类描述了虚拟机一些信息。
Runtime的部分源码如下:
public class Runtime {
private static Runtime currentRuntime = new Runtime();
public static Runtime getRuntime() {
return currentRuntime;
}
/** Don't let anyone else instantiate this class */
private Runtime() {}
// ...
}
当然也可以用静态代码块,一个意思:
public class Singleton {
private static Singleton instance = null;
static {
instance = new Singleton();
}
private Singleton (){}
public static Singleton getInstance() {
return instance;
}
}
2、静态内部类来实现:
public class Singleton {
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
private Singleton() { }
public static Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
这种方式相比第1种有所优化,就是使用了lazy-loading。Singleton类被装载了,但是instance并没有立即初始化。
因为SingletonHolder类没有被主动使用,只有显示通过调用getInstance方法时,才会显示装载SingletonHolder类,从而实例化instance。
3、使用枚举的方式:
public enum Singleton {
INSTANCE;
public void otherMethod() {
}
}
这种方式是Effective Java作者Josh Bloch 提倡的方式,它不仅能避免多线程同步问题,而且还能防止反序列化重新创建新的对象,可谓是很坚强的壁垒。不过目前很少被使用。
面试官:以上几种答案,其实现原理都是利用借助了类加载的时候初始化单例。即借助了ClassLoader的线程安全机制。
所谓ClassLoader的线程安全机制,就是ClassLoader的loadClass方法在加载类的时候使用了synchronized关键字。也正是因为这样, 除非被重写,这个方法默认在整个装载过程中都是同步的,也就是保证了线程安全。
所以,以上各种方法,虽然并没有显示的使用synchronized,但是还是其底层实现原理还是用到了synchronized。
面试官:除了这种以外,还有其他方式吗?
答:还可以使用Java并发包中的Lock实现。
4、Java并发包下的lock实现:
public class Singleton {
private static Singleton instance;
private static final Lock lock = new ReentrantLock();
private Singleton() { }
public static Singleton getInstance() {
try {
lock.lock();
if (instance == null)
instance = new Singleton();
return instance;
} finally {
lock.unlock();
}
}
}
面试官:本质上还是在使用锁,不使用锁的话,有办法实现线程安全的单例吗?
答:有的,那就是使用CAS。
5、CAS实现:
CAS是项乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。实现单例的方式如下:
public class Singleton {
private static final AtomicReference<Singleton> INSTANCE = new AtomicReference<Singleton>();
private Singleton() { }
public static Singleton getInstance() {
for (;;) {
Singleton singleton = INSTANCE.get();
if (null != singleton) {
return singleton;
}
singleton = new Singleton();
if (INSTANCE.compareAndSet(null, singleton)) {
return singleton;
}
}
}
}
1.10.3 面试前准备建议和思路
1. 掌握单例模式实现方式
- 饿汉式
- 懒汉式(含DCL双检锁)
- 静态内部类
- 枚举
2. 研究Java并发机制
- 类加载机制
- final字段的语义
- volatile的内存屏障
- CAS操作原理
3. 准备替代方案
- 基于类初始化的解决方案
- 使用AtomicReference的CAS实现
- 枚举单例的实现原理
1.10.4 📌面试必过的回答思路
1. 标准回答结构
- 方案总览:列举可行的无锁实现方式
- 详细实现:重点讲解最优方案
- 原理分析:解释线程安全保证
- 优劣对比:与其他方案的比较
- 扩展思考:其他考量因素
2. 完整回答示例
"在不使用synchronized和Lock的情况下,我有以下几种线程安全单例的实现方案:
1. 饿汉式(静态初始化)
public class Singleton {
private static final Singleton INSTANCE = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return INSTANCE;
}
}
- 优点:简单、线程安全
- 缺点:非懒加载,类加载时就初始化
2. 静态内部类(推荐方案)
public class Singleton {
private Singleton() {}
private static class Holder {
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE;
}
}
- 线程安全原理 :
- 利用类加载机制保证线程安全
- JVM在类加载时会加锁,保证静态成员只初始化一次
- Holder类只有在getInstance()调用时才会加载
- 优点:懒加载、无锁、高效
3. 枚举单例(最安全方案)
public enum Singleton {
INSTANCE;
public void doSomething() {
// 业务方法
}
}
- 线程安全原理 :
- 枚举的实例创建是线程安全的
- 由JVM保证只会初始化一次
- 优点 :
- 天然防止反射攻击
- 自动处理序列化/反序列化
- 代码简洁
4. CAS实现(展示并发知识)
public class Singleton {
private static final AtomicReference<Singleton> INSTANCE = new AtomicReference<>();
private Singleton() {}
public static Singleton getInstance() {
for (;;) {
Singleton instance = INSTANCE.get();
if (instance != null) {
return instance;
}
instance = new Singleton();
if (INSTANCE.compareAndSet(null, instance)) {
return instance;
}
}
}
}
- 优点:展示CAS知识
- 缺点:实际项目不推荐,可能创建多余实例
最佳实践建议:
对于大多数场景,推荐使用静态内部类方案,它兼顾了:
- 线程安全(JVM类加载保证)
- 懒加载(按需初始化)
- 无锁高性能
- 实现简单
如果需要绝对安全(防止反射和序列化破坏单例),则选择枚举实现。
在实际框架设计中,Spring等主流框架通常采用静态内部类方案实现单例,而Java标准库中的Runtime类则使用饿汉式实现。"
3. 回答加分项
- 分析各方案在反射、序列化场景下的表现
- 讨论JVM层级的实现原理
- 对比不同JDK版本的优化
- 分享实际框架中的使用案例
- 提及其他语言中的单例实现
1.11 索引失效的问题是如何排查的,有哪些种情况?
1.11.1 面试考察重点
1. 面试官的考察目标
- SQL优化能力:对索引工作原理的理解深度
- 问题诊断技巧:系统化排查问题的能力
- 实战经验:实际处理索引失效问题的经验
- 知识广度:对各类失效场景的掌握程度
2. 关键关注点
- 排查工具:使用哪些工具诊断索引失效
- 失效场景:常见的索引失效模式
- 解决方案:如何修复失效问题
- 预防措施:如何避免索引失效
1.11.2 面试核心知识点详解
1.11.2.1 索引失效定义
在MySQL中,索引是用来加快检索数据库记录的一种数据结构。
索引失效指的是在进行查询操作时,本应该使用索引来提升查询效率的场景下,数据库没有利用索引,而是采用了全表扫描的方式,这会大大增加查询时间和系统负担。
1.11.2.2 为什么排查索引失效
排查索引失效的原因是至关重要的,主要有以下方面:
-
提高查询效率:索引的主要目的是加快数据检索速度。当索引失效时,数据库系统可能退回到更慢的查询方法,如全表扫描,这会显著增加查询时间和降低整体性能。
-
降低服务器负载:使用索引可以显著减少数据库处理查询所需处理的数据量,从而减少CPU使用率和IO读写。如果索引失效,数据库必须加载更多数据,这会增加服务器的负载和资源消耗。
1.11.2.3 索引失效的原因及排查(How)
以下的学生信息表举例
CREATE TABLE `student_info` (
`student_id` int(11) NOT NULL AUTO_INCREMENT,
`student_name` varchar(50) NOT NULL,
`student_age` int(11) DEFAULT NULL,
`enrollment_date` datetime DEFAULT NULL,
PRIMARY KEY (`student_id`),
UNIQUE KEY `student_name` (`student_name`),
KEY `student_age` (`student_age`),
KEY `enrollment_date` (`enrollment_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- 表的索引情况:
- 总结来说,表
student_info
有四个字段上定义了索引:- 一个主键索引
student_id
- 一个唯一索引
student_name
- 以及两个普通索引
student_age
和enrollment_date
。
- 一个主键索引
① 索引列参与计算
- 正常的通过 age 去做查询
-
走的是 student_age 的索引
explain select * from student_info where student_age=21;
-
- 如果索引列参与了计算进行查询
-
索引失效
explain select * from student_info where student_age+1 =21;
-
- 如果不是对列进行计算,而是对列等号右侧的值进行计算,结果还是走索引的。
② 对索引列进行函数操作
-
正常的查询------>走索引
explain select * from student_info where enrollment_date = '2022-09-04 08:00:00';
-
如果对查询的字段加上函数操作时,索引失效
explain select * from student_info where YEAR(enrollment_date) = 2022;
查询中使用了 OR 两边有范围查询 > 或 <
-
正常情况查询,查询使用
student_name
索引explain select * from student_info where student_name='Helen' and student_age>15;
- 如果使用了 OR 进行查询,两边包含范围查询 > 或 <
- 此时索引失效
-
如果没有范围查询下使用 OR 还是正常走索引
explain select * from student_info where student_name='Helen' or student_age=18;
like 操作:以 % 开头的 like 查询
- 以 % 开头的 LIKE 查询比如 LIKE '%abc';;
不等于比较 !=
- 在MySQL中
!=
比较有可能会导致不走索引,但如果对 id 进行 != 比较,是有可能走索引的。 !=
比较是否走索引,与索引的选择、数据分布情况有关,不单是由于查询包含!=
而引起的。
order by
- 如果使用 order by 时,表中的数据量很小,数据库会直接在内存中进行排序,而不使用索引
使用 IN
- 使用
IN
的时候,有可能走索引,也有可能不走索引。当在IN
的取值范围比较大的时候有可能会导致索引失效,走全表扫描(NOT IN
和IN
的失效场景相同)。
1.11.2.4 索引失效的排查
使用 explain 排查
- 和 MySQL 慢查询的排查类似,使用 Explain 语句来进行排查。\
需要关注的字段:type、key、extra
- 我们可以根据 key、type、extra 来判断一条语句是否走了索引。
- 一般走索引的情况 :
- key 值不为 null
- type 值应该为 ref、eq_ref、range、const 这几个
- extra 的话如果是 NULL,或者 using indedx,using index condition 都是可以的
索引失效情况
- 如果一条语句出现了
type
值为all
、key 为null
,extra = Using where
此时是索引失效了
此时就需要排查索引失效的原因
-
- 索引是否符合最左前缀匹配
- 查询语句出现以上 7 种情况
1.11.2.5 总结:索引失效知识点小结
MySQL中什么情况下会出现索引失效?如何排查索引失效?
1.11.3 面试前准备建议和思路
1. 掌握排查工具链
- 执行计划分析:EXPLAIN/EXPLAIN ANALYZE
- 性能监控:慢查询日志、performance_schema
- 诊断命令:SHOW INDEX、ANALYZE TABLE
- 可视化工具:MySQL Workbench、Percona Toolkit
2. 研究常见失效场景
- 索引列参与运算
- 隐式类型转换
- 最左前缀原则违反
- 使用OR条件不当
- 优化器误判
3. 准备实际案例
- 从执行计划发现的全表扫描案例
- 字符集不匹配导致的索引失效
- 统计信息不准确导致的优化器误判
1.11.4 📌面试必过的回答思路
1. 标准回答结构
- 排查流程:系统化的诊断步骤
- 失效场景:分类说明常见情况
- 解决方案:各类场景的修复方法
- 预防体系:如何建立防护措施
2. 完整回答示例
"我会通过以下系统化流程排查和解决索引失效问题:
1. 诊断排查四步法
(1) 执行计划分析
EXPLAIN SELECT * FROM users WHERE username = 'admin';
重点关注:
- type列:ALL表示全表扫描,可能索引失效
- key列:显示实际使用的索引
- Extra列:Using where/filesort/temporary等警告
(2) 索引状态检查
SHOW INDEX FROM users;
确认:
- 索引是否存在
- Cardinality值是否准确
- 索引类型是否合适
(3) 性能监控
- 开启慢查询日志定位问题SQL
- 使用performance_schema监控索引使用频率
(4) 统计信息更新
ANALYZE TABLE users;
优化器依赖的统计信息可能过期
2. 八大常见索引失效场景
(1) 隐式类型转换
-- 字符串字段使用数字查询(username是varchar类型)
SELECT * FROM users WHERE username = 123;
解决方案:确保类型一致
(2) 对索引列使用函数或运算
-- 对索引列使用函数
SELECT * FROM orders WHERE YEAR(create_time) = 2023;
解决方案:改为范围查询
SELECT * FROM orders
WHERE create_time BETWEEN '2023-01-01' AND '2023-12-31';
(3) 违反最左前缀原则
-- 复合索引是(idx_type_status)
SELECT * FROM articles WHERE status = 1;
解决方案:调整查询条件或索引顺序
(4) 使用不等于(!=或<>)
SELECT * FROM products WHERE price != 100;
解决方案:考虑改写为范围查询
(5) LIKE以通配符开头
SELECT * FROM users WHERE name LIKE '%张%';
解决方案:使用全文索引或改写为'张%'
(6) OR条件使用不当
-- name有索引但age没有
SELECT * FROM employees WHERE name = '张三' OR age = 30;
解决方案:改用UNION ALL或为age添加索引
(7) 优化器误判
- 数据分布不均匀
- 统计信息不准确
解决方案:使用FORCE INDEX或更新统计信息
(8) 字符集不匹配
- 表与连接表的字符集不一致
解决方案:统一字符集或使用CONVERT函数
3. 预防体系建立
(1) 开发规范
- 制定索引使用规范
- 代码审查时检查SQL写法
- 使用ORM框架的合理配置
(2) 监控体系
- 部署索引使用率监控
- 定期检查冗余索引
- 设置慢查询告警
(3) 性能测试
- 上线前进行SQL评审
- 压测验证索引效果
- A/B测试对比优化方案
在实际电商项目中,我们曾通过这套方法发现并解决了商品搜索接口的索引失效问题。原SQL因对索引列使用了DATE_FORMAT函数导致全表扫描,优化后QPS从50提升到2000,平均响应时间从800ms降到15ms。"
3. 回答加分项
- 展示EXPLAIN的实际分析过程
- 提供不同类型数据库的差异
- 讨论新版本特性(如MySQL8.0的不可见索引)
- 分享实际性能优化数据
- 提出自动化检测方案
1.12 说一说多级缓存是如何应用的?
1.12.1 面试考察重点
1. 面试官的考察目标
- 架构设计能力:对缓存体系的理解深度
- 性能优化思维:如何通过缓存提升系统性能
- 技术广度:对不同缓存技术的掌握程度
- 实战经验:实际应用缓存解决业务问题的能力
- 问题预见性:对缓存问题的认知和解决方案
2. 关键关注点
- 缓存层级:各级缓存的定位和作用
- 技术选型:各层缓存的技术实现
- 数据一致性:如何保证多级缓存的一致性
- 失效策略:缓存更新和淘汰机制
- 异常处理:缓存击穿、雪崩等问题的预防
1.12.2 面试核心知识点详解
1.12.2.1 多级缓存架构-主从模式
我以上面的"长文章流量热点"的例子来说明一下。为了防止文章下载阅读出现热点时,造成后端存储服务的压力太大,我们一般会通过缓存来进行下载时的加速。比如说,我们可以通过文章的唯一ID来进行哈希,并且通过缓存的一主多从模式来进行部署,主从模式的部署大概如下图:-
一般来说,主从模式下,主库只用于数据写入和更新,从库只用于数据读取。当然,这个也不是一定的。
比如,在写多读少的场景下,也可以让主库承担一部分的数据读取工作。当缓存的数据读取QPS比较大的情况下,可以通过增加从库的方式来提升整体缓存层的抗读取能力。
主从模式是最常见的、使用最多的缓存应用模式。但是主从模式在某些突发流量的场景下会存在一些问题,就比如刚刚提到的"长文章流量热点"问题。
我们对某篇长文章的唯一ID来进行哈希,在主从模式下,一篇文章只会映射到一个从库节点上。虽然能够通过增加从库副本数来提升服务端对一篇文章的读取能力,但由于文章大小比较大,即使是多从库副本,对于千兆网卡的从库实例机器来说,带宽层面也很难抗住这个热点。举个例子,单台机器120MB带宽,对于1MB大小的文章来说,如果QPS到1000的话,至少需要8个实例才可以抗住。
另外,多从库副本是对主库数据的完整拷贝,从成本上考虑也是非常不划算的。除了带宽问题,对于某些QPS很高的资源请求来说,如果采用的是单主单从结构,一旦从库宕机,瞬间会有大量请求直接穿透到DB存储层,可能直接会导致资源不可用。
1.12.2.2 多级缓存架构-L1+主从模式
为了解决主从模式下,单点峰值过高导致单机带宽和热点数据在从库宕机后,造成后端资源瞬时压力的问题,我们可以参考CPU和主存的结构,在主从缓存结构前面再增加一层L1缓存层。
L1缓存,顾名思义一般它的容量会比较小,用于缓存极热的数据。那么,为什么L1缓存可以解决主从模式下的带宽问题和穿透问题呢?
我们来看一下,L1+主从模式的部署和访问形式:-
L1缓存作为最前端的缓存层,在用户请求的时候,会先从L1缓存进行查询。如果L1缓存中没有,再从主从缓存里查询,查询到的结果也会回种一份到L1缓存中。
与主从缓存模式不一样的地方是:L1缓存有分组的概念,一组L1可以有多个节点,每一组L1缓存都是一份全量的热数据,一个系统可以提供多组L1缓存,同一个数据的请求会轮流落到每一组L1里面。
比如同一个文章ID,第一次请求会落到第一组L1缓存,第二次请求可能就落到第二组L1缓存。通过穿透后的回种,最后每一组L1缓存,都会缓存到同一篇文章。通过这种方式,同一篇文章就有多个L1缓存节点来抗读取的请求量了。
而且,L1缓存一般采用LRU(Least Recently Used)方式进行淘汰,这样既能减少L1缓存的内存使用量,也能保证热点数据不会被淘汰掉。并且,采用L1+主从的双层模式,即使有某一层节点出现宕机的情况,也不会导致请求都穿透到后端存储上,导致资源出现问题。
1.12.2.3 多级缓存架构-本地缓存+L1+主从的多层模式
通过L1缓存+主从缓存的双层架构,我们用较少的资源解决了热点峰值的带宽问题和单点穿透问题。
但有的时候,面对一些极热的热点峰值,我们可能需要增加多组L1才能抗住带宽的需要。不过内存毕竟是比较昂贵的成本,所以有没有更好的平衡极热峰值和缓存成本的方法呢?
对于大部分请求量较大的应用来说,应用层机器的部署一般不会太少。如果我们的应用服务器本身也能够承担一部分数据缓存的工作,就能充分利用应用层机器的带宽和极少的内存,来低成本地解决带宽问题了。那么,这种方式是否可以实现呢?
答案是可以的,这种本地缓存+L1缓存+主从缓存的多级缓存模式,也是业界比较成熟的方案了。多级缓存模式的整体流程大概如下图:-
本地缓存一般位于应用服务器的部署机器上,使用应用服务器本身的少量内存。它是应用层获取数据的第一道缓存,应用层获取数据时先访问本地缓存,如果未命中,再通过远程从L1缓存层获取,最终获取到的数据再回种到本地缓存中。
通过增加本地缓存,依托应用服务器的多部署节点,基本就能完全解决热点数据带宽的问题。而且,相比较从远程L1缓存获取数据,本地缓存离应用和用户设备更近,性能上也会更好一些。
但是使用本地缓存有一个需要考虑的问题,那就是数据的一致性问题。
还是以"长文章"为例。我们的服务端可能会随时接收到用户需要修改文章内容的请求,这个时候,对于本地缓存来说,由于应用服务器的部署机器随着扩缩容的改变,其数量不一定是固定的,所以修改后的数据如何同步到本地缓存中,就是一个比较复杂和麻烦的事情了。
要解决本地缓存一致性问题,业界比较折中的方式是:对本地缓存采用"短过期时间"的方式,来平衡本地缓存命中率和数据更新一致性的问题。比如说,针对"长文章"的本地缓存,我们可以采用5秒过期的策略,淘汰后再从中央缓存获取新的数据。这种方式对于大部分业务场景来说,在产品层面上也是都能接受的。
1.12.3 面试前准备建议和思路
1. 掌握缓存层级体系
- 客户端缓存
- CDN缓存
- 反向代理缓存
- 应用级缓存
- 分布式缓存
- 持久化缓存
2. 研究主流缓存技术
- 客户端:浏览器缓存、LocalStorage
- 网络层:Nginx缓存、Varnish
- 应用层:Caffeine、Ehcache
- 分布式:Redis、Memcached
- 持久化:MySQL缓冲池、PageCache
1.12.4 📌面试必过的回答思路
1. 标准回答结构
- 架构总览:多级缓存整体架构
- 层级详解:各层缓存的技术实现
- 数据流转:请求在各层间的流动
- 一致性保障:缓存更新策略
- 实战案例:实际应用场景
2. 完整回答示例
"多级缓存是通过构建分层次的缓存体系来最大化提升系统性能的架构模式,我的典型设计方案如下:
1. 五级缓存架构设计
(1) 客户端缓存(L1)
-
技术实现:HTTP缓存头、LocalStorage
-
缓存策略:
Cache-Control: max-age=3600
ETag: "xyz123" -
适用场景:静态资源、用户个性化配置
(2) CDN缓存(L2)
- 技术实现:阿里云CDN、Cloudflare
- 缓存策略:
- 边缘节点缓存静态内容
- 动态内容通过边缘计算处理
- 命中率:可达95%+
(3) 反向代理缓存(L3)
-
技术实现:Nginx proxy_cache
-
配置示例:
proxy_cache_path /data/nginx/cache levels=1:2 keys_zone=my_cache:10m inactive=60m;
proxy_cache_key "schemerequest_methodhostrequest_uri"; -
特点:缓存API响应、HTML片段
(4) 应用级缓存(L4)
-
技术实现:Caffeine、Guava Cache
-
典型配置:
Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build(); -
优势:纳秒级访问、避免进程间通信
(5) 分布式缓存(L5)
- 技术实现:Redis集群、Memcached
- 高级特性:
- Redis模块:RediSearch、RedisJSON
- 持久化策略:RDB+AOF混合
- 内存管理:LRU+TTL淘汰
2. 数据流转流程
- 请求首先到达客户端缓存
- 未命中则查询CDN边缘节点
- 动态请求到达Nginx反向代理
- 应用先检查本地缓存
- 最后查询分布式缓存
- 仍不存在则回源数据库
3. 一致性保障方案
-
主动更新:
// 数据库更新后同步各层缓存
void updateProduct(Product product) {
// 更新DB
db.update(product);
// 删除缓存
redis.del("product:"+product.id);
// 发送缓存失效事件
eventBus.publish(new CacheEvictEvent(product.id));
} -
延迟双删:应对缓存与DB不一致
-
版本控制:通过数据版本号避免脏读
4. 实战优化案例
在电商秒杀系统中,我们设计了如下多级缓存:
- 静态页面元素:CDN缓存(1分钟过期)
- 商品基本信息:Nginx缓存+Redis(10秒)
- 库存数据:本地缓存+Redis(500ms刷新)
- 用户个性化数据:仅客户端缓存
通过这种设计,将峰值QPS从5k提升到50k,数据库负载降低90%。关键优化点包括:
- 热点数据在本地缓存做预加载
- 使用Redis Lua脚本保证原子性
- 采用分层过期策略平衡实时性
5. 异常处理机制
-
缓存击穿:
public Product getProduct(String id) {
// 布隆过滤器前置检查
if (!bloomFilter.mightContain(id)) {
return null;
}
// 分布式锁防击穿
return lockManager.executeUnderLock(id, () -> {
// 缓存查询逻辑
});
} -
缓存雪崩:随机过期时间+熔断降级
-
热点Key:本地缓存+分片策略"
3. 回答加分项
- 展示各层缓存的命中率监控
- 讨论缓存GC调优经验
- 分析不同业务场景的缓存策略差异
- 分享缓存预热方案
- 提出智能缓存淘汰算法
待续....
二、📌 Java业务场景题
这个文档是帮助正在找工作以及准备找工作的同学,在面试之前去复习和突击的一种方式。
适合已经在技术领域有一定积累,然后不确定面试切入点,所以可以通过这个面试文档来预热和巩固。
想直接通过刷面试文档找到工作的同学也要注意,面试文档的内容是静态的,但是面试过程是动态的,面试官对于某一个领域的考察,通常是通过连环问的方式去问,所以在面试之前,求职者要对 Java 相关技术有一个体系化的了解,从而更好地突出自己的综合能力。
在科技日新月异的今天,软件开发行业正经历着前所未有的变革。Java,作为企业级应用开发的中流砥柱,其生态系统也在不断进化,从微服务架构的普及到云原生技术的兴起,再到AI与大数据的深度融合,Java程序员的角色和技能需求随之迭代升级。面对这样的行业背景,如何在求职路上脱颖而出,成为每位开发者必须深思的问题。
随着Java这个赛道的不断内卷,这两年,Java程序员的面试,从原来的常规八股文(有标准答案)到现在,以项目、场景问题、技术深度思考为主,逐步转变成没有标准答案,需要大家基于自己的理解和技术底蕴来回答。
那针对市场中新的需求,有没有最新的面试攻略呢? 其实也是有的,虽然说没有标准答案,但是我们可以针对如今市场的面试变化,来针对性的设计一些面试回答的思路,让大家有一个清晰和明确的方向。
🔔
这里有什么?
- 针对2024年面试行情的变化设计的面试场景题以及回答思路
- 如何快速通过面试的详细攻略
- 简历优化技巧
请你详细介绍一下扫码登录的实现原理?
扫码登录功能主要分为三个阶段:待扫描、已扫描待确认、已确认。
整体流程图如图。

下面分阶段来看看设计原理。
1、待扫描阶段
首先是待扫描阶段,这个阶段是 PC 端跟服务端的交互过程。
每次用户打开PC端登陆请求,系统返回一个唯一的二维码ID,并将二维码ID的信息绘制成二维码返回给用户。
这里的二维码ID一定是唯一的,后续流程会将二维码ID跟身份信息绑定,不唯一的话就会造成你登陆了其他用户的账号或者其他用户登陆你的账号。
此时在 PC 端会启动一个定时器,轮询查询二维码是否被扫描。
如果移动端未扫描的话,那么一段时间后二维码将会失效。
这个阶段的交互过程如下图所示。

2、已扫描待确认阶段
第二个阶段是已扫描待确认阶段,主要是移动端跟服务端交互的过程。
首先移动端扫描二维码,获取二维码 ID,然后将手机端登录的凭证(token)和 二维码 ID 作为参数发送给服务端
此时的手机在之前已经是登录的,不存在没登录的情况。
服务端接受请求后,会将 token 与二维码 ID 关联,然后会生成一个临时token,这个 token 会返回给移动端,临时 token 用作确认登录的凭证。
PC 端的定时器,会轮询到二维码的状态已经发生变化,会将 PC 端的二维码更新为已扫描,请在手机端确认。
这里为什么要有手机端确认的操作?
假设没有确认这个环节,很容易就会被坏人拦截token去冒充登录。所以二维码扫描一定要有这个确认的页面,让用户去确认是否进行登录。
另外,二维码扫描确认之后,再往用户app或手机等发送登录提醒的通知,告知如果不是本人登录的,则建议用户立即修改密码。
这个阶段是交互过程如下图所示。

3、已确认
扫码登录的最后阶段,用户点击确认登录,移动端携带上一步骤中获取的临时 token访问服务端。
服务端校对完成后,会更新二维码状态,并且给 PC 端生成一个正式的 token。
后续 PC 端就是持有这个 token 访问服务端。
这个阶段是交互过程如下图所示。

[重要]电商平台中订单未支付过期如何实现自动关单?
📌
日常开发中,我们经常遇到这种业务场景,如:外卖订单超 30 分钟未支付,则自动取订单;用户注册成功 15 分钟后,发短信息通知用户等等。这就延时任务处理场景。
在电商,支付等系统中,一设都是先创建订单(支付单),再给用户一定的时间进行支付,如果没有按时支付的话,就需要把之前的订单(支付单)取消掉。这种类以的场景有很多,还有比如到期自动收货,超时自动退款,下单后自动发送短信等等都是类似的业务问题。
2.1 定时任务
通过定时任务关闭订单,是一种成本很低,实现也很容易的方案。通过简单的几行代码,写一个定时任务,定期扫描数据库中的订单,如果时间过期,就将其状态更新为关闭即可。

优点:实现容易,成本低,基本不依赖其他组件。
缺点:
时间可能不够精确。由于定时任务扫描的间隔是固定的,所以可能造成一些订单已经过期了一段时间才被扫描到,订单关闭的时间比正常时间晚一些。
增加了数据库的压力。随着订单的数量越来越多,扫描的成本也会越来越大,执行时间也会被拉长,可能导致某些应该被关闭的订单迟迟没有被关闭。
总结 :采用定时任务的方案比较适合对时间要求不是很敏感,并且数据量不太多的业务场景。
2.2 JDK 延迟队列 DelayQueue

DelayQueue 是 JDK 提供的一个无界队列,我们可以看到,DelayQueue 队列中的元素需要实现 Delayed,它只提供了一个方法,就是获取过期时间。

用户的订单生成以后,设置过期时间比如 30 分钟,放入定义好的 DelayQueue,然后创建一个线程,在线程中通过 while(true)不断的从 DelayQueue 中获取过期的数据。
优点:不依赖任何第三方组件,连数据库也不需要了,实现起来也方便。
缺点:
因为 DelayQueue 是一个无界队列,如果放入的订单过多,会造成 JVM OOM。
DelayQueue 基于 JVM 内存,如果 JVM 重启了,那所有数据就丢失了。
总结 :DelayQueue 适用于数据量较小,且丢失也不影响主业务的场景,比如内部系统的一些非重要通知,就算丢失,也不会有太大影响。
2.3 redis 过期监听
redis 是一个高性能的 KV 数据库,除了用作缓存以外,其实还提供了过期监听的功能。
在 redis.conf 中,配置 notify-keyspace-events Ex 即可开启此功能。
然后在代码中继承 KeyspaceEventMessageListener,实现 onMessage 就可以监听过期的数据量。
public abstract class KeyspaceEventMessageListener implements MessageListener, InitializingBean, DisposableBean {
private static final Topic TOPIC_ALL_KEYEVENTS = new PatternTopic("__keyevent@*");
//...省略部分代码
public void init() {
if (StringUtils.hasText(keyspaceNotificationsConfigParameter)) {
RedisConnection connection = listenerContainer.getConnectionFactory().getConnection();
try {
Properties config = connection.getConfig("notify-keyspace-events");
if (!StringUtils.hasText(config.getProperty("notify-keyspace-events"))) {
connection.setConfig("notify-keyspace-events", keyspaceNotificationsConfigParameter);
}
} finally {
connection.close();
}
}
doRegister(listenerContainer);
}
protected void doRegister(RedisMessageListenerContainer container) {
listenerContainer.addMessageListener(this, TOPIC_ALL_KEYEVENTS);
}
//...省略部分代码
@Override
public void afterPropertiesSet() throws Exception {
init();
}
}
通过以上源码,我们可以发现,其本质也是注册一个 listener,利用 redis 的发布订阅,当 key 过期时,发布过期消息(key)到 Channel :keyevent@*:expired 中。
在实际的业务中,我们可以将订单的过期时间设置比如 30 分钟,然后放入到 redis。30 分钟之后,就可以消费这个 key,然后做一些业务上的后置动作,比如检查用户是否支付。
优点: 由于 redis 的高性能,所以我们在设置 key,或者消费 key 时,速度上是可以保证的。
缺点:由于 redis 的 key 过期策略原因,当一个 key 过期时,redis 无法保证立刻将其删除,自然我们的监听事件也无法第一时间消费到这个 key,所以会存在一定的延迟。另外,在 redis5.0 之前,订阅发布中的消息并没有被持久化,自然也没有所谓的确认机制。所以一旦消费消息的过程中我们的客户端发生了宕机,这条消息就彻底丢失了。
总结:redis 的过期订阅相比于其他方案没有太大的优势,在实际生产环境中,用得相对较少。
2.4 Redisson 分布式延迟队列
Redisson 是一个基于 redis 实现的 Java 驻内存数据网格,它不仅提供了一系列的分布式的 Java 常用对象,还提供了许多分布式服务。
Redisson 除了提供我们常用的分布式锁外,还提供了一个分布式延迟队列 RDelayedQueue,他是一种基于 zset 结构实现的延迟队列,其实现类是 RedissonDelayedQueue。

优点:使用简单,并且其实现类中大量使用 lua 脚本保证其原子性,不会有并发重复问题。
缺点:需要依赖 redis(如果这算一种缺点的话)。
总结:Redisson 是 redis 官方推荐的 JAVA 客户端,提供了很多常用的功能,使用简单、高效,推荐大家尝试使用。
2.5 RocketMQ 延迟消息
延迟消息,当消息写入到 Broker 后,不会立刻被消费者消费,需要等待指定的时长后才可被消费处理的消息,称为延时消息。
在订单创建之后,我们就可以把订单作为一条消息投递到 rocketmq,并将延迟时间设置为 30 分钟,这样,30 分钟后我们定义的 consumer 就可以消费到这条消息,然后检查用户是否支付了这个订单。
通过延迟消息,我们就可以将业务解耦,极大地简化我们的代码逻辑。
优点:可以使代码逻辑清晰,系统之间完全解耦,只需关注生产及消费消息即可。另外其吞吐量极高,最多可以支撑万亿级的数据量。
缺点:相对来说 mq 是重量级的组件,引入 mq 之后,随之而来的消息丢失、幂等性问题等都加深了系统的复杂度。
总结:通过 mq 进行系统业务解耦,以及对系统性能削峰填谷已经是当前高性能系统的标配。
2.6 RabbitMQ 死信队列
除了 RocketMQ 的延迟队列,RabbitMQ 的死信队列也可以实现消息延迟功能。
当 RabbitMQ 中的一条正常消息,因为过了存活时间(TTL 过期)、队列长度超限、被消费者拒绝等原因无法被消费时,就会被当成一条死信消息,投递到死信队列。
基于这样的机制,我们可以给消息设置一个 ttl,然后故意不消费消息,等消息过期就会进入死信队列,我们再消费死信队列即可。
通过这样的方式,就可以达到同 RocketMQ 延迟消息一样的效果。
优点:同 RocketMQ 一样,RabbitMQ 同样可以使业务解耦,基于其集群的扩展性,也可以实现高可用、高性能的目标。
缺点:死信队列本质还是一个队列,队列都是先进先出,如果队头的消息过期时间比较长,就会导致后面过期的消息无法得到及时消费,造成消息阻塞。
总结:除了增加系统复杂度之外,死信队列的阻塞问题也是需要我们重点关注的。
如何设计一个秒杀系统
💡
说起秒杀,我想你肯定不陌生,这两年,从双十一购物到春节抢红包,再到12306抢火车票,"秒杀"的场景处处可见。简单来说,秒杀就是在同一个时刻有大量的请求争抢购买同一个商品并完成交易的过程,用技术的行话来说就是大量的并发读和并发写。
不管是哪一门语言,并发都是程序员们最为头疼的部分。同样,对于一个软件而言也是这样,你可以很快增删改查做出一个秒杀系统,但是要让它支持高并发访问就没那么容易了。比如说,如何让系统面对百万级的请求流量不出故障?如何保证高并发情况下数据的一致性写?完全靠堆服务器来解决吗?这显然不是最好的解决方案。
在我看来,秒杀系统本质上就是一个满足大并发、高性能和高可用的分布式系统。今天,我们就来聊聊,如何在满足一个良好架构的分布式系统基础上,针对秒杀这种业务做到极致的性能改进。
如何才能更好地理解秒杀系统呢?我觉得作为一个程序员,你首先需要从高维度出发,从整体上思考问题。在我看来,秒杀其实主要解决两个问题,一个是并发读,一个是并发写。并发读的核心优化理念是尽量减少用户到服务端来"读"数据,或者让他们读更少的数据;并发写的处理原则也一样,它要求我们在数据库层面独立出来一个库,做特殊的处理。另外,我们还要针对秒杀系统做一些保护,针对意料之外的情况设计兜底方案,以防止最坏的情况发生。
而从一个架构师的角度来看,要想打造并维护一个超大流量并发读写、高性能、高可用的系统,在整个用户请求路径上从浏览器到服务端我们要遵循几个原则,就是要保证用户请求的数据尽量少、请求数尽量少、路径尽量短、依赖尽量少,并且不要有单点。这些关键点我会在后面的文章里重点讲解。
其实,秒杀的整体架构可以概括为"稳、准、快"几个关键字。
所谓"稳",就是整个系统架构要满足高可用,流量符合预期时肯定要稳定,就是超出预期时也同样不能掉链子,你要保证秒杀活动顺利完成,即秒杀商品顺利地卖出去,这个是最基本的前提。
然后就是"准",就是秒杀10台iPhone,那就只能成交10台,多一台少一台都不行。一旦库存不对,那平台就要承担损失,所以"准"就是要求保证数据的一致性。
最后再看"快","快"其实很好理解,它就是说系统的性能要足够高,否则你怎么支撑这么大的流量呢?不光是服务端要做极致的性能优化,而且在整个请求链路上都要做协同的优化,每个地方快一点,整个系统就完美了。
所以从技术角度上看"稳、准、快",就对应了我们架构上的高可用、一致性和高性能的要求,我们的专栏也将主要围绕这几个方面来展开,具体如下。
- 高性能。 秒杀涉及大量的并发读和并发写,因此支持高并发访问这点非常关键。本专栏将从设计数据的动静分离方案、热点的发现与隔离、请求的削峰与分层过滤、服务端的极致优化这4个方面重点介绍。
- 一致性。 秒杀中商品减库存的实现方式同样关键。可想而知,有限数量的商品在同一时刻被很多倍的请求同时来减库存,减库存又分为"拍下减库存""付款减库存"以及预扣等几种,在大并发更新的过程中都要保证数据的准确性,其难度可想而知。因此,我将用一篇文章来专门讲解如何设计秒杀减库存方案。
- 高可用。 虽然我介绍了很多极致的优化思路,但现实中总难免出现一些我们考虑不到的情况,所以要保证系统的高可用和正确性,我们还要设计一个PlanB来兜底,以便在最坏情况发生时仍然能够从容应对。专栏的最后,我将带你思考可以从哪些环节来设计兜底方案。
3.1 设计秒杀系统应该注意的5个架构原则
不管是哪一门语言,并发都是程序员们最为头疼的部分。同样,对于一个软件而言也是这样,你可以很快增删改查做出一个秒杀系统,但是要让它支持高并发访问就没那么容易了。比如说,如何让系统面对百万级的请求流量不出故障?如何保证高并发情况下数据的一致性写?完全靠堆服务器来解决吗?这显然不是最好的解决方案。
在我看来,秒杀系统本质上就是一个满足大并发、高性能和高可用的分布式系统。今天,我们就来聊聊,如何在满足一个良好架构的分布式系统基础上,针对秒杀这种业务做到极致的性能改进。
3.1.1 架构原则:"4要1不要"
如果你是一个架构师,你首先要勾勒出一个轮廓,想一想如何构建一个超大流量并发读写、高性能,以及高可用的系统,这其中有哪些要素需要考虑。我把这些要素总结为"4要1不要"。
- 数据要尽量少
所谓"数据要尽量少",首先是指用户请求的数据能少就少。请求的数据包括上传给系统的数据和系统返回给用户的数据(通常就是网页)。
为啥"数据要尽量少"呢?因为首先这些数据在网络上传输需要时间,其次不管是请求数据还是返回数据都需要服务器做处理,而服务器在写网络时通常都要做压缩和字符编码,这些都非常消耗CPU,所以减少传输的数据量可以显著减少CPU的使用。例如,我们可以简化秒杀页面的大小,去掉不必要的页面装修效果,等等。
其次,"数据要尽量少"还要求系统依赖的数据能少就少,包括系统完成某些业务逻辑需要读取和保存的数据,这些数据一般是和后台服务以及数据库打交道的。调用其他服务会涉及数据的序列化和反序列化,而这也是CPU的一大杀手,同样也会增加延时。而且,数据库本身也容易成为一个瓶颈,所以和数据库打交道越少越好,数据越简单、越小则越好。
- 请求数要尽量少
用户请求的页面返回后,浏览器渲染这个页面还要包含其他的额外请求,比如说,这个页面依赖的CSS/JavaScript、图片,以及Ajax请求等等都定义为"额外请求",这些额外请求应该尽量少。因为浏览器每发出一个请求都多少会有一些消耗,例如建立连接要做三次握手,有的时候有页面依赖或者连接数限制,一些请求(例如JavaScript)还需要串行加载等。另外,如果不同请求的域名不一样的话,还涉及这些域名的DNS解析,可能会耗时更久。所以你要记住的是,减少请求数可以显著减少以上这些因素导致的资源消耗。
例如,减少请求数最常用的一个实践就是合并CSS和JavaScript文件,把多个JavaScript文件合并成一个文件,在URL中用逗号隔开(https://g.xxx.com/tm/xx-b/4.0.94/mods/??module-preview/index.xtpl.js,module-jhs/index.xtpl.js,module-focus/index.xtpl.js)。这种方式在服务端仍然是单个文件各自存放,只是服务端会有一个组件解析这个URL,然后动态把这些文件合并起来一起返回。
- 路径要尽量短
所谓"路径",就是用户发出请求到返回数据这个过程中,需求经过的中间的节点数。
通常,这些节点可以表示为一个系统或者一个新的Socket连接(比如代理服务器只是创建一个新的Socket连接来转发请求)。每经过一个节点,一般都会产生一个新的Socket连接。
然而,每增加一个连接都会增加新的不确定性。从概率统计上来说,假如一次请求经过5个节点,每个节点的可用性是99.9%的话,那么整个请求的可用性是:99.9%的5次方,约等于99.5%。
所以缩短请求路径不仅可以增加可用性,同样可以有效提升性能(减少中间节点可以减少数据的序列化与反序列化),并减少延时(可以减少网络传输耗时)。
要缩短访问路径有一种办法,就是多个相互强依赖的应用合并部署在一起,把远程过程调用(RPC)变成JVM内部之间的方法调用。在《大型网站技术架构演进与性能优化》一书中,我也有一章介绍了这种技术的详细实现。
- 依赖要尽量少
所谓依赖,指的是要完成一次用户请求必须依赖的系统或者服务,这里的依赖指的是强依赖。
举个例子,比如说你要展示秒杀页面,而这个页面必须强依赖商品信息、用户信息,还有其他如优惠券、成交列表等这些对秒杀不是非要不可的信息(弱依赖),这些弱依赖在紧急情况下就可以去掉。
要减少依赖,我们可以给系统进行分级,比如0级系统、1级系统、2级系统、3级系统,0级系统如果是最重要的系统,那么0级系统强依赖的系统也同样是最重要的系统,以此类推。
注意,0级系统要尽量减少对1级系统的强依赖,防止重要的系统被不重要的系统拖垮。例如支付系统是0级系统,而优惠券是1级系统的话,在极端情况下可以把优惠券给降级,防止支付系统被优惠券这个1级系统给拖垮。
- 不要有单点
系统中的单点可以说是系统架构上的一个大忌,因为单点意味着没有备份,风险不可控,我们设计分布式系统最重要的原则就是"消除单点"。
那如何避免单点呢?我认为关键点是避免将服务的状态和机器绑定,即把服务无状态化,这样服务就可以在机器中随意移动。
如何那把服务的状态和机器解耦呢?这里也有很多实现方式。例如把和机器相关的配置动态化,这些参数可以通过配置中心来动态推送,在服务启动时动态拉取下来,我们在这些配置中心设置一些规则来方便地改变这些映射关系。
应用无状态化是有效避免单点的一种方式,但是像存储服务本身很难无状态化,因为数据要存储在磁盘上,本身就要和机器绑定,那么这种场景一般要通过冗余多个备份的方式来解决单点问题。
前面介绍了这些设计上的一些原则,但是你有没有发现,我一直说的是"尽量"而不是"绝对"?
我想你肯定会问是不是请求最少就一定最好,我的答案是"不一定"。我们曾经把有些CSS内联进页面里,这样做可以减少依赖一个CSS的请求从而加快首页的渲染,但是同样也增大了页面的大小,又不符合"数据要尽量少"的原则,这种情况下我们为了提升首屏的渲染速度,只把首屏的HTML依赖的CSS内联进来,其他CSS仍然放到文件中作为依赖加载,尽量实现首屏的打开速度与整个页面加载性能的平衡。
所以说,架构是一种平衡的艺术,而最好的架构一旦脱离了它所适应的场景,一切都将是空谈。我希望你记住的是,这里所说的几点都只是一个个方向,你应该尽量往这些方向上去努力,但也要考虑平衡其他因素。
3.1.2 不同场景下的不同架构案例
前面我说了一些架构上的原则,那么针对"秒杀"这个场景,怎样才是一个好的架构呢?下面我以淘宝早期秒杀系统架构的演进为主线,来帮你梳理不同的请求体量下,我认为的最佳秒杀系统架构。
如果你想快速搭建一个简单的秒杀系统,只需要把你的商品购买页面增加一个"定时上架"功能,仅在秒杀开始时才让用户看到购买按钮,当商品的库存卖完了也就结束了。这就是当时第一个版本的秒杀系统实现方式。
但随着请求量的加大(比如从1w/s到了10w/s的量级),这个简单的架构很快就遇到了瓶颈,因此需要做架构改造来提升系统性能。这些架构改造包括:
- 把秒杀系统独立出来单独打造一个系统,这样可以有针对性地做优化,例如这个独立出来的系统就减少了店铺装修的功能,减少了页面的复杂度;
- 在系统部署上也独立做一个机器集群,这样秒杀的大流量就不会影响到正常的商品购买集群的机器负载;
- 将热点数据(如库存数据)单独放到一个缓存系统中,以提高"读性能";
- 增加秒杀答题,防止有秒杀器抢单。
此时的系统架构变成了下图这个样子。最重要的就是,秒杀详情成为了一个独立的新系统,另外核心的一些数据放到了缓存(Cache)中,其他的关联系统也都以独立集群的方式进行部署。

图1 改造后的系统架构
然而这个架构仍然支持不了超过100w/s的请求量,所以为了进一步提升秒杀系统的性能,我们又对架构做进一步升级,比如:
- 对页面进行彻底的动静分离,使得用户秒杀时不需要刷新整个页面,而只需要点击抢宝按钮,借此把页面刷新的数据降到最少;
- 在服务端对秒杀商品进行本地缓存,不需要再调用依赖系统的后台服务获取数据,甚至不需要去公共的缓存集群中查询数据,这样不仅可以减少系统调用,而且能够避免压垮公共缓存集群。
- 增加系统限流保护,防止最坏情况发生。
经过这些优化,系统架构变成了下图中的样子。在这里,我们对页面进行了进一步的静态化,秒杀过程中不需要刷新整个页面,而只需要向服务端请求很少的动态数据。而且,最关键的详情和交易系统都增加了本地缓存,来提前缓存秒杀商品的信息,热点数据库也做了独立部署,等等。

图2 进一步改造后的系统架构
从前面的几次升级来看,其实越到后面需要定制的地方越多,也就是越"不通用"。例如,把秒杀商品缓存在每台机器的内存中,这种方式显然不适合太多的商品同时进行秒杀的情况,因为单机的内存始终有限。所以要取得极致的性能,就要在其他地方(比如,通用性、易用性、成本等方面)有所牺牲。
3.2 如何才能做好动静分离?有哪些方案可选?
如果你在一个业务飞速发展的公司里,并且你在深度参与公司内类秒杀类系统的架构或者开发工作,那么你迟早会想到动静分离的方案。为什么?很简单,秒杀的场景中,对于系统的要求其实就三个字:快、准、稳。
那怎么才能"快"起来呢?我觉得抽象起来讲,就只有两点,一点是提高单次请求的效率,一点是减少没必要的请求。今天我们聊到的"动静分离"其实就是瞄着这个大方向去的。
3.2.1 何为动静数据
那到底什么才是动静分离呢?所谓"动静分离",其实就是把用户请求的数据(如HTML页面)划分为"动态数据"和"静态数据"。
简单来说,"动态数据"和"静态数据"的主要区别就是看页面中输出的数据是否和URL、浏览者、时间、地域相关,以及是否含有Cookie等私密数据。比如说:
- 很多媒体类的网站,某一篇文章的内容不管是你访问还是我访问,它都是一样的。所以它就是一个典型的静态数据,但是它是个动态页面。
- 我们如果现在访问淘宝的首页,每个人看到的页面可能都是不一样的,淘宝首页中包含了很多根据访问者特征推荐的信息,而这些个性化的数据就可以理解为动态数据了。
这里再强调一下,我们所说的静态数据,不能仅仅理解为传统意义上完全存在磁盘上的HTML页面,它也可能是经过Java系统产生的页面,但是它输出的页面本身不包含上面所说的那些因素。也就是所谓"动态"还是"静态",并不是说数据本身是否动静,而是数据中是否含有和访问者相关的个性化数据。
还有一点要注意,就是页面中"不包含",指的是"页面的HTML源码中不含有",这一点务必要清楚。
理解了静态数据和动态数据,我估计你很容易就能想明白"动静分离"这个方案的来龙去脉了。分离了动静数据,我们就可以对分离出来的静态数据做缓存,有了缓存之后,静态数据的"访问效率"自然就提高了。
那么,怎样对静态数据做缓存呢?我在这里总结了几个重点。
第一,你应该把静态数据缓存到离用户最近的地方。静态数据就是那些相对不会变化的数据,因此我们可以把它们缓存起来。缓存到哪里呢?常见的就三种,用户浏览器里、CDN上或者在服务端的Cache中。你应该根据情况,把它们尽量缓存到离用户最近的地方。
第二,静态化改造就是要直接缓存HTTP连接。相较于普通的数据缓存而言,你肯定还听过系统的静态化改造。静态化改造是直接缓存HTTP连接而不是仅仅缓存数据,如下图所示,Web代理服务器根据请求URL,直接取出对应的HTTP响应头和响应体然后直接返回,这个响应过程简单得连HTTP协议都不用重新组装,甚至连HTTP请求头也不需要解析。

图1 静态化改造
第三,让谁来缓存静态数据也很重要。不同语言写的Cache软件处理缓存数据的效率也各不相同。以Java为例,因为Java系统本身也有其弱点(比如不擅长处理大量连接请求,每个连接消耗的内存较多,Servlet容器解析HTTP协议较慢),所以你可以不在Java层做缓存,而是直接在Web服务器层上做,这样你就可以屏蔽Java语言层面的一些弱点;而相比起来,Web服务器(如Nginx、Apache、Varnish)也更擅长处理大并发的静态文件请求。
3.2.2 如何做动静分离的改造
理解了动静态数据的"why"和"what",接下来我们就要看"how"了。我们如何把动态页面改造成适合缓存的静态页面呢?其实也很简单,就是去除前面所说的那几个影响因素,把它们单独分离出来,做动静分离。
下面,我以典型的商品详情系统为例来详细介绍。这里,你可以先打开京东或者淘宝的商品详情页,看看这个页面里都有哪些动静数据。我们从以下5个方面来分离出动态内容。
- URL唯一化。商品详情系统天然地就可以做到URL唯一化,比如每个商品都由ID来标识,那么http://item.xxx.com/item.htm?id=xxxx就可以作为唯一的URL标识。为啥要URL唯一呢?前面说了我们是要缓存整个HTTP连接,那么以什么作为Key呢?就以URL作为缓存的Key,例如以id=xxx这个格式进行区分。
- 分离浏览者相关的因素。浏览者相关的因素包括是否已登录,以及登录身份等,这些相关因素我们可以单独拆分出来,通过动态请求来获取。
- 分离时间因素。服务端输出的时间也通过动态请求获取。
- 异步化地域因素。详情页面上与地域相关的因素做成异步方式获取,当然你也可以通过动态请求方式获取,只是这里通过异步获取更合适。
- 去掉Cookie。服务端输出的页面包含的Cookie可以通过代码软件来删除,如Web服务器Varnish可以通过unset req.http.cookie 命令去掉Cookie。注意,这里说的去掉Cookie并不是用户端收到的页面就不含Cookie了,而是说,在缓存的静态数据中不含有Cookie。
分离出动态内容之后,如何组织这些内容页就变得非常关键了。这里我要提醒你一点,因为这其中很多动态内容都会被页面中的其他模块用到,如判断该用户是否已登录、用户ID是否匹配等,所以这个时候我们应该将这些信息JSON化(用JSON格式组织这些数据),以方便前端获取。
前面我们介绍里用缓存的方式来处理静态数据。而动态内容的处理通常有两种方案:ESI(Edge Side Includes)方案和CSI(Client Side Include)方案。
- ESI方案(或者SSI):即在Web代理服务器上做动态内容请求,并将请求插入到静态页面中,当用户拿到页面时已经是一个完整的页面了。这种方式对服务端性能有些影响,但是用户体验较好。
- CSI方案。即单独发起一个异步JavaScript 请求,以向服务端获取动态内容。这种方式服务端性能更佳,但是用户端页面可能会延时,体验稍差。
3.2.3 动静分离的几种架构方案
前面我们通过改造把静态数据和动态数据做了分离,那么如何在系统架构上进一步对这些动态和静态数据重新组合,再完整地输出给用户呢?
这就涉及对用户请求路径进行合理的架构了。根据架构上的复杂度,有3种方案可选:
- 实体机单机部署;
- 统一Cache层;
- 上CDN。
方案1:实体机单机部署
这种方案是将虚拟机改为实体机,以增大Cache的容量,并且采用了一致性Hash分组的方式来提升命中率。这里将Cache分成若干组,是希望能达到命中率和访问热点的平衡。Hash分组越少,缓存的命中率肯定就会越高,但短板是也会使单个商品集中在一个分组中,容易导致Cache被击穿,所以我们应该适当增加多个相同的分组,来平衡访问热点和命中率的问题。
这里我给出了实体机单机部署方案的结构图,如下:

图2 Nginx+Cache+Java结构实体机单机部署
实体机单机部署有以下几个优点:
- 没有网络瓶颈,而且能使用大内存;
- 既能提升命中率,又能减少Gzip压缩;
- 减少Cache失效压力,因为采用定时失效方式,例如只缓存3秒钟,过期即自动失效。
这个方案中,虽然把通常只需要虚拟机或者容器运行的Java应用换成实体机,优势很明显,它会增加单机的内存容量,但是一定程度上也造成了CPU的浪费,因为单个的Java进程很难用完整个实体机的CPU。
另外就是,一个实体机上部署了Java应用又作为Cache来使用,这造成了运维上的高复杂度,所以这是一个折中的方案。如果你的公司里,没有更多的系统有类似需求,那么这样做也比较合适,如果你们有多个业务系统都有静态化改造的需求,那还是建议把Cache层单独抽出来公用比较合理,如下面的方案2所示。
方案2:统一Cache层
所谓统一Cache层,就是将单机的Cache统一分离出来,形成一个单独的Cache集群。统一Cache层是个更理想的可推广方案,该方案的结构图如下:

图3 统一Cache
将Cache层单独拿出来统一管理可以减少运维成本,同时也方便接入其他静态化系统。此外,它还有一些优点。
- 单独一个Cache层,可以减少多个应用接入时使用Cache的成本。这样接入的应用只要维护自己的Java系统就好,不需要单独维护Cache,而只关心如何使用即可。
- 统一Cache的方案更易于维护,如后面加强监控、配置的自动化,只需要一套解决方案就行,统一起来维护升级也比较方便。
- 可以共享内存,最大化利用内存,不同系统之间的内存可以动态切换,从而能够有效应对各种攻击。
这种方案虽然维护上更方便了,但是也带来了其他一些问题,比如缓存更加集中,导致:
- Cache层内部交换网络成为瓶颈;
- 缓存服务器的网卡也会是瓶颈;
- 机器少风险较大,挂掉一台就会影响很大一部分缓存数据。
要解决上面这些问题,可以再对Cache做Hash分组,即一组Cache缓存的内容相同,这样能够避免热点数据过度集中导致新的瓶颈产生。
方案3:上CDN
在将整个系统做动静分离后,我们自然会想到更进一步的方案,就是将Cache进一步前移到CDN上,因为CDN离用户最近,效果会更好。
但是要想这么做,有以下几个问题需要解决。
- 失效问题。前面我们也有提到过缓存时效的问题,不知道你有没有理解,我再来解释一下。谈到静态数据时,我说过一个关键词叫"相对不变",它的言外之意是"可能会变化"。比如一篇文章,现在不变,但如果你发现个错别字,是不是就会变化了?如果你的缓存时效很长,那用户端在很长一段时间内看到的都是错的。所以,这个方案中也是,我们需要保证CDN可以在秒级时间内,让分布在全国各地的Cache同时失效,这对CDN的失效系统要求很高。
- 命中率问题。Cache最重要的一个衡量指标就是"高命中率",不然Cache的存在就失去了意义。同样,如果将数据全部放到全国的CDN上,必然导致Cache分散,而Cache分散又会导致访问请求命中同一个Cache的可能性降低,那么命中率就成为一个问题。
- 发布更新问题。如果一个业务系统每周都有日常业务需要发布,那么发布系统必须足够简洁高效,而且你还要考虑有问题时快速回滚和排查问题的简便性。
从前面的分析来看,将商品详情系统放到全国的所有CDN节点上是不太现实的,因为存在失效问题、命中率问题以及系统的发布更新问题。那么是否可以选择若干个节点来尝试实施呢?答案是"可以",但是这样的节点需要满足几个条件:
- 靠近访问量比较集中的地区;
- 离主站相对较远;
- 节点到主站间的网络比较好,而且稳定;
- 节点容量比较大,不会占用其他CDN太多的资源。
最后,还有一点也很重要,那就是:节点不要太多。
基于上面几个因素,选择CDN的二级Cache比较合适,因为二级Cache数量偏少,容量也更大,让用户的请求先回源的CDN的二级Cache中,如果没命中再回源站获取数据,部署方式如下图所示:

图4 CDN化部署方案
使用CDN的二级Cache作为缓存,可以达到和当前服务端静态化Cache类似的命中率,因为节点数不多,Cache不是很分散,访问量也比较集中,这样也就解决了命中率问题,同时能够给用户最好的访问体验,是当前比较理想的一种CDN化方案。
除此之外,CDN化部署方案还有以下几个特点:
- 把整个页面缓存在用户浏览器中;
- 如果强制刷新整个页面,也会请求CDN;
- 实际有效请求,只是用户对"刷新抢宝"按钮的点击。
这样就把90%的静态数据缓存在了用户端或者CDN上,当真正秒杀时,用户只需要点击特殊的"刷新抢宝"按钮,而不需要刷新整个页面。这样一来,系统只是向服务端请求很少的有效数据,而不需要重复请求大量的静态数据。
秒杀的动态数据和普通详情页面的动态数据相比更少,性能也提升了3倍以上。所以"抢宝"这种设计思路,让我们不用刷新页面就能够很好地请求到服务端最新的动态数据。
3.3 有针对性地处理好系统的"热点数据"
假设你的系统中存储有几十亿上百亿的商品,而每天有千万级的商品被上亿的用户访问,那么肯定有一部分被大量用户访问的热卖商品,这就是我们常说的"热点商品"。
这些热点商品中最极端的例子就是秒杀商品,它们在很短时间内被大量用户执行访问、添加购物车、下单等操作,这些操作我们就称为"热点操作"。那么问题来了:这些热点对系统有啥影响,我们非要关注这些热点吗?
3.3.1 为什么要关注热点
我们一定要关注热点,因为热点会对系统产生一系列的影响。
首先,热点请求会大量占用服务器处理资源,虽然这个热点可能只占请求总量的亿分之一,然而却可能抢占90%的服务器资源,如果这个热点请求还是没有价值的无效请求,那么对系统资源来说完全是浪费。
其次,即使这些热点是有效的请求,我们也要识别出来做针对性的优化,从而用更低的代价来支撑这些热点请求。
既然热点对系统来说这么重要,那么热点到底包含哪些内容呢?
3.3.2 什么是"热点"
热点分为热点操作 和热点数据。所谓"热点操作",例如大量的刷新页面、大量的添加购物车、双十一零点大量的下单等都属于此类操作。对系统来说,这些操作可以抽象为"读请求"和"写请求",这两种热点请求的处理方式大相径庭,读请求的优化空间要大一些,而写请求的瓶颈一般都在存储层,优化的思路就是根据CAP理论做平衡,这个内容我在"减库存"一文再详细介绍。
而"热点数据"比较好理解,那就是用户的热点请求对应的数据。而热点数据又分为"静态热点数据"和"动态热点数据"。
所谓"静态热点数据",就是能够提前预测的热点数据。例如,我们可以通过卖家报名的方式提前筛选出来,通过报名系统对这些热点商品进行打标。另外,我们还可以通过大数据分析来提前发现热点商品,比如我们分析历史成交记录、用户的购物车记录,来发现哪些商品可能更热门、更好卖,这些都是可以提前分析出来的热点。
所谓"动态热点数据",就是不能被提前预测到的,系统在运行过程中临时产生的热点。例如,卖家在抖音上做了广告,然后商品一下就火了,导致它在短时间内被大量购买。
由于热点操作是用户的行为,我们不好改变,但能做一些限制和保护,所以本文我主要针对热点数据来介绍如何进行优化。
3.3.3 发现热点数据
前面,我介绍了如何对单个秒杀商品的页面数据进行动静分离,以便针对性地对静态数据做优化处理,那么另外一个关键的问题来了:如何发现这些秒杀商品,或者更准确地说,如何发现热点商品呢?
你可能会说"参加秒杀的商品就是秒杀商品啊",没错,关键是系统怎么知道哪些商品参加了秒杀活动呢?所以,你要有一个机制提前来区分普通商品和秒杀商品。
我们从发现静态热点和发现动态热点两个方面来看一下。
发现静态热点数据
如前面讲的,静态热点数据可以通过商业手段,例如强制让卖家通过报名参加的方式提前把热点商品筛选出来,实现方式是通过一个运营系统,把参加活动的商品数据进行打标,然后通过一个后台系统对这些热点商品进行预处理,如提前进行缓存。但是这种通过报名提前筛选的方式也会带来新的问题,即增加卖家的使用成本,而且实时性较差,也不太灵活。
不过,除了提前报名筛选这种方式,你还可以通过技术手段提前预测,例如对买家每天访问的商品进行大数据计算,然后统计出TOP N的商品,我们可以认为这些TOP N的商品就是热点商品。
发现动态热点数据
我们可以通过卖家报名或者大数据预测这些手段来提前预测静态热点数据,但这其中有一个痛点,就是实时性较差,如果我们的系统能在秒级内自动发现热点商品那就完美了。
能够动态地实时发现热点不仅对秒杀商品,对其他热卖商品也同样有价值,所以我们需要想办法实现热点的动态发现功能。
这里我给出一个动态热点发现系统的具体实现。
- 构建一个异步的系统,它可以收集交易链路上各个环节中的中间件产品的热点Key,如Nginx、缓存、RPC服务框架等这些中间件(一些中间件产品本身已经有热点统计模块)。
- 建立一个热点上报和可以按照需求订阅的热点服务的下发规范,主要目的是通过交易链路上各个系统(包括详情、购物车、交易、优惠、库存、物流等)访问的时间差,把上游已经发现的热点透传给下游系统,提前做好保护。比如,对于大促高峰期,详情系统是最早知道的,在统一接入层上Nginx模块统计的热点URL。
- 将上游系统收集的热点数据发送到热点服务台,然后下游系统(如交易系统)就会知道哪些商品会被频繁调用,然后做热点保护。
这里我给出了一个图,其中用户访问商品时经过的路径有很多,我们主要是依赖前面的导购页面(包括首页、搜索页面、商品详情、购物车等)提前识别哪些商品的访问量高,通过这些系统中的中间件来收集热点数据,并记录到日志中。

图1 一个动态热点发现系统
我们通过部署在每台机器上的Agent把日志汇总到聚合和分析集群中,然后把符合一定规则的热点数据,通过订阅分发系统再推送到相应的系统中。你可以是把热点数据填充到Cache中,或者直接推送到应用服务器的内存中,还可以对这些数据进行拦截,总之下游系统可以订阅这些数据,然后根据自己的需求决定如何处理这些数据。
打造热点发现系统时,我根据以往经验总结了几点注意事项。
- 这个热点服务后台抓取热点数据日志最好采用异步方式,因为"异步"一方面便于保证通用性,另一方面又不影响业务系统和中间件产品的主流程。
- 热点服务发现和中间件自身的热点保护模块并存,每个中间件和应用还需要保护自己。热点服务台提供热点数据的收集和订阅服务,便于把各个系统的热点数据透明出来。
- 热点发现要做到接近实时(3s内完成热点数据的发现),因为只有做到接近实时,动态发现才有意义,才能实时地对下游系统提供保护。
3.3.4 处理热点数据
处理热点数据通常有几种思路:一是优化,二是限制,三是隔离。
先来说说优化。优化热点数据最有效的办法就是缓存热点数据,如果热点数据做了动静分离,那么可以长期缓存静态数据。但是,缓存热点数据更多的是"临时"缓存,即不管是静态数据还是动态数据,都用一个队列短暂地缓存数秒钟,由于队列长度有限,可以采用LRU淘汰算法替换。
再来说说限制。限制更多的是一种保护机制,限制的办法也有很多,例如对被访问商品的ID做一致性Hash,然后根据Hash做分桶,每个分桶设置一个处理队列,这样可以把热点商品限制在一个请求队列里,防止因某些热点商品占用太多的服务器资源,而使其他请求始终得不到服务器的处理资源。
最后介绍一下隔离。秒杀系统设计的第一个原则就是将这种热点数据隔离出来,不要让1%的请求影响到另外的99%,隔离出来后也更方便对这1%的请求做针对性的优化。
具体到"秒杀"业务,我们可以在以下几个层次实现隔离。
- 业务隔离。把秒杀做成一种营销活动,卖家要参加秒杀这种营销活动需要单独报名,从技术上来说,卖家报名后对我们来说就有了已知热点,因此可以提前做好预热。
- 系统隔离。系统隔离更多的是运行时的隔离,可以通过分组部署的方式和另外99%分开。秒杀可以申请单独的域名,目的也是让请求落到不同的集群中。
- 数据隔离。秒杀所调用的数据大部分都是热点数据,比如会启用单独的Cache集群或者MySQL数据库来放热点数据,目的也是不想0.01%的数据有机会影响99.99%数据。
当然了,实现隔离有很多种办法。比如,你可以按照用户来区分,给不同的用户分配不同的Cookie,在接入层,路由到不同的服务接口中;再比如,你还可以在接入层针对URL中的不同Path来设置限流策略。服务层调用不同的服务接口,以及数据层通过给数据打标来区分等等这些措施,其目的都是把已经识别出来的热点请求和普通的请求区分开。
3.4 流量削峰应该怎么做?
如果你看过秒杀系统的流量监控图的话,你会发现它是一条直线,就在秒杀开始那一秒是一条很直很直的线,这是因为秒杀请求在时间上高度集中于某一特定的时间点。这样一来,就会导致一个特别高的流量峰值,它对资源的消耗是瞬时的。
但是对秒杀这个场景来说,最终能够抢到商品的人数是固定的,也就是说100人和10000人发起请求的结果都是一样的,并发度越高,无效请求也越多。
但是从业务上来说,秒杀活动是希望更多的人来参与的,也就是开始之前希望有更多的人来刷页面,但是真正开始下单时,秒杀请求并不是越多越好。因此我们可以设计一些规则,让并发的请求更多地延缓,而且我们甚至可以过滤掉一些无效请求。
3.4.1 为什么要削峰
为什么要削峰呢?或者说峰值会带来哪些坏处?
我们知道服务器的处理资源是恒定的,你用或者不用它的处理能力都是一样的,所以出现峰值的话,很容易导致忙到处理不过来,闲的时候却又没有什么要处理。但是由于要保证服务质量,我们的很多处理资源只能按照忙的时候来预估,而这会导致资源的一个浪费。
这就好比因为存在早高峰和晚高峰的问题,所以有了错峰限行的解决方案。削峰的存在,一是可以让服务端处理变得更加平稳,二是可以节省服务器的资源成本。针对秒杀这一场景,削峰从本质上来说就是更多地延缓用户请求的发出,以便减少和过滤掉一些无效请求,它遵从"请求数要尽量少"的原则。
今天,我就来介绍一下流量削峰的一些操作思路:排队、答题、分层过滤。这几种方式都是无损(即不会损失用户的发出请求)的实现方案,当然还有些有损的实现方案,包括我们后面要介绍的关于稳定性的一些办法,比如限流和机器负载保护等一些强制措施也能达到削峰保护的目的,当然这都是不得已的一些措施,因此就不归类到这里了。
3.4.2 排队
要对流量进行削峰,最容易想到的解决方案就是用消息队列来缓冲瞬时流量,把同步的直接调用转换成异步的间接推送,中间通过一个队列在一端承接瞬时的流量洪峰,在另一端平滑地将消息推送出去。在这里,消息队列就像"水库"一样, 拦蓄上游的洪水,削减进入下游河道的洪峰流量,从而达到减免洪水灾害的目的。
用消息队列来缓冲瞬时流量的方案,如下图所示:

图1 用消息队列来缓冲瞬时流量
但是,如果流量峰值持续一段时间达到了消息队列的处理上限,例如本机的消息积压达到了存储空间的上限,消息队列同样也会被压垮,这样虽然保护了下游的系统,但是和直接把请求丢弃也没多大的区别。就像遇到洪水爆发时,即使是有水库恐怕也无济于事。
除了消息队列,类似的排队方式还有很多,例如:
- 利用线程池加锁等待也是一种常用的排队方式;
- 先进先出、先进后出等常用的内存排队算法的实现方式;
- 把请求序列化到文件中,然后再顺序地读文件(例如基于MySQL binlog的同步机制)来恢复请求等方式。
可以看到,这些方式都有一个共同特征,就是把"一步的操作"变成"两步的操作",其中增加的一步操作用来起到缓冲的作用。
说到这里你可能会说,这样一来增加了访问请求的路径啊,并不符合我们介绍的"4要1不要"原则。没错,的确看起来不太合理,但是如果不增加一个缓冲步骤,那么在一些场景下系统很可能会直接崩溃,所以最终还是需要你做出妥协和平衡。
3.4.3 答题
你是否还记得,最早期的秒杀只是纯粹地刷新页面和点击购买按钮,它是后来才增加了答题功能的。那么,为什么要增加答题功能呢?
这主要是为了增加购买的复杂度,从而达到两个目的。
第一个目的是防止部分买家使用秒杀器在参加秒杀时作弊。2011年秒杀非常火的时候,秒杀器也比较猖獗,因而没有达到全民参与和营销的目的,所以系统增加了答题来限制秒杀器。增加答题后,下单的时间基本控制在2s后,秒杀器的下单比例也大大下降。答题页面如下图所示。

图2 答题页面
第二个目的其实就是延缓请求,起到对请求流量进行削峰的作用,从而让系统能够更好地支持瞬时的流量高峰。这个重要的功能就是把峰值的下单请求拉长,从以前的1s之内延长到2s~10s。这样一来,请求峰值基于时间分片了。这个时间的分片对服务端处理并发非常重要,会大大减轻压力。而且,由于请求具有先后顺序,靠后的请求到来时自然也就没有库存了,因此根本到不了最后的下单步骤,所以真正的并发写就非常有限了。这种设计思路目前用得非常普遍,如当年支付宝的"咻一咻"、微信的"摇一摇"都是类似的方式。
这里,我重点说一下秒杀答题的设计思路。

图3 秒杀答题
如上图所示,整个秒杀答题的逻辑主要分为3部分。
- 题库生成模块,这个部分主要就是生成一个个问题和答案,其实题目和答案本身并不需要很复杂,重要的是能够防止由机器来算出结果,即防止秒杀器来答题。
- 题库的推送模块,用于在秒杀答题前,把题目提前推送给详情系统和交易系统。题库的推送主要是为了保证每次用户请求的题目是唯一的,目的也是防止答题作弊。
- 题目的图片生成模块,用于把题目生成为图片格式,并且在图片里增加一些干扰因素。这也同样是为防止机器直接来答题,它要求只有人才能理解题目本身的含义。这里还要注意一点,由于答题时网络比较拥挤,我们应该把题目的图片提前推送到CDN上并且要进行预热,不然的话当用户真正请求题目时,图片可能加载比较慢,从而影响答题的体验。
其实真正答题的逻辑比较简单,很好理解:当用户提交的答案和题目对应的答案做比较,如果通过了就继续进行下一步的下单逻辑,否则就失败。我们可以把问题和答案用下面这样的key来进行MD5加密:
- 问题key:userId+itemId+question_Id+time+PK
- 答案key:userId+itemId+answer+PK
验证的逻辑如下图所示:

图4 答题的验证逻辑
注意,这里面的验证逻辑,除了验证问题的答案以外,还包括用户本身身份的验证,例如是否已经登录、用户的Cookie是否完整、用户是否重复频繁提交等。
除了做正确性验证,我们还可以对提交答案的时间做些限制,例如从开始答题到接受答案要超过1s,因为小于1s是人为操作的可能性很小,这样也能防止机器答题的情况。
3.4.4 分层过滤
前面介绍的排队和答题要么是少发请求,要么对发出来的请求进行缓冲,而针对秒杀场景还有一种方法,就是对请求进行分层过滤,从而过滤掉一些无效的请求。分层过滤其实就是采用"漏斗"式设计来处理请求的,如下图所示。

图5 分层过滤
假如请求分别经过CDN、前台读系统(如商品详情系统)、后台系统(如交易系统)和数据库这几层,那么:
- 大部分数据和流量在用户浏览器或者CDN上获取,这一层可以拦截大部分数据的读取;
- 经过第二层(即前台系统)时数据(包括强一致性的数据)尽量得走Cache,过滤一些无效的请求;
- 再到第三层后台系统,主要做数据的二次检验,对系统做好保护和限流,这样数据量和请求就进一步减少;
- 最后在数据层完成数据的强一致性校验。
这样就像漏斗一样,尽量把数据量和请求量一层一层地过滤和减少了。
分层过滤的核心思想是:在不同的层次尽可能地过滤掉无效请求,让"漏斗"最末端的才是有效请求。而要达到这种效果,我们就必须对数据做分层的校验。
分层校验的基本原则是:
- 将动态请求的读数据缓存(Cache)在Web端,过滤掉无效的数据读;
- 对读数据不做强一致性校验,减少因为一致性校验产生瓶颈的问题;
- 对写数据进行基于时间的合理分片,过滤掉过期的失效请求;
- 对写请求做限流保护,将超出系统承载能力的请求过滤掉;
- 对写数据进行强一致性校验,只保留最后有效的数据。
分层校验的目的是:在读系统中,尽量减少由于一致性校验带来的系统瓶颈,但是尽量将不影响性能的检查条件提前,如用户是否具有秒杀资格、商品状态是否正常、用户答题是否正确、秒杀是否已经结束、是否非法请求、营销等价物是否充足等;在写数据系统中,主要对写的数据(如"库存")做一致性检查,最后在数据库层保证数据的最终准确性(如"库存"不能减为负数)。
3.5 影响性能的因素有哪些?又该如何提高系统的性能?
你想要提升性能,首先肯定要知道哪些因素对于系统性能的影响最大,然后再针对这些具体的因素想办法做优化,是不是这个逻辑?
那么,哪些因素对性能有影响呢?在回答这个问题之前,我们先定义一下"性能",服务设备不同对性能的定义也是不一样的,例如CPU主要看主频、磁盘主要看IOPS(Input/Output Operations Per Second,即每秒进行读写操作的次数)。
而今天我们讨论的主要是系统服务端性能,一般用QPS(Query Per Second,每秒请求数)来衡量,还有一个影响和QPS也息息相关,那就是响应时间(Response Time,RT),它可以理解为服务器处理响应的耗时。
正常情况下响应时间(RT)越短,一秒钟处理的请求数(QPS)自然也就会越多,这在单线程处理的情况下看起来是线性的关系,即我们只要把每个请求的响应时间降到最低,那么性能就会最高。
但是你可能想到响应时间总有一个极限,不可能无限下降,所以又出现了另外一个维度,即通过多线程,来处理请求。这样理论上就变成了"总QPS =(1000ms / 响应时间)× 线程数量",这样性能就和两个因素相关了,一个是一次响应的服务端耗时,一个是处理请求的线程数。
接下来,我们一起看看这个两个因素到底会造成什么样的影响。
首先,我们先来看看响应时间和QPS有啥关系。
对于大部分的Web系统而言,响应时间一般都是由CPU执行时间和线程等待时间(比如RPC、IO等待、Sleep、Wait等)组成,即服务器在处理一个请求时,一部分是CPU本身在做运算,还有一部分是在各种等待。
理解了服务器处理请求的逻辑,估计你会说为什么我们不去减少这种等待时间。很遗憾,根据我们实际的测试发现,减少线程等待时间对提升性能的影响没有我们想象得那么大,它并不是线性的提升关系,这点在很多代理服务器(Proxy)上可以做验证。
如果代理服务器本身没有CPU消耗,我们在每次给代理服务器代理的请求加个延时,即增加响应时间,但是这对代理服务器本身的吞吐量并没有多大的影响,因为代理服务器本身的资源并没有被消耗,可以通过增加代理服务器的处理线程数,来弥补响应时间对代理服务器的QPS的影响。
其实,真正对性能有影响的是CPU的执行时间。这也很好理解,因为CPU的执行真正消耗了服务器的资源。经过实际的测试,如果减少CPU一半的执行时间,就可以增加一倍的QPS。
也就是说,我们应该致力于减少CPU的执行时间。
其次,我们再来看看线程数对QPS的影响。
单看"总QPS"的计算公式,你会觉得线程数越多QPS也就会越高,但这会一直正确吗?显然不是,线程数不是越多越好,因为线程本身也消耗资源,也受到其他因素的制约。例如,线程越多系统的线程切换成本就会越高,而且每个线程也都会耗费一定内存。
那么,设置什么样的线程数最合理呢?其实很多多线程的场景都有一个默认配置,即"线程数 = 2 * CPU核数 + 1"。除去这个配置,还有一个根据最佳实践得出来的公式:
线程数 = [(线程等待时间 + 线程CPU时间) / 线程CPU时间] × CPU数量
当然,最好的办法是通过性能测试来发现最佳的线程数。
换句话说,要提升性能我们就要减少CPU的执行时间,另外就是要设置一个合理的并发线程数,通过这两方面来显著提升服务器的性能。
现在,你知道了如何来快速提升性能,那接下来你估计会问,我应该怎么发现系统哪里最消耗CPU资源呢?
3.5.1 如何发现瓶颈
就服务器而言,会出现瓶颈的地方有很多,例如CPU、内存、磁盘以及网络等都可能会导致瓶颈。此外,不同的系统对瓶颈的关注度也不一样,例如对缓存系统而言,制约它的是内存,而对存储型系统来说I/O更容易是瓶颈。
这个专栏中,我们定位的场景是秒杀,它的瓶颈更多地发生在CPU上。
那么,如何发现CPU的瓶颈呢?其实有很多CPU诊断工具可以发现CPU的消耗,最常用的就是JProfiler和Yourkit这两个工具,它们可以列出整个请求中每个函数的CPU执行时间,可以发现哪个函数消耗的CPU时间最多,以便你有针对性地做优化。
当然还有一些办法也可以近似地统计CPU的耗时,例如通过jstack定时地打印调用栈,如果某些函数调用频繁或者耗时较多,那么那些函数就会多次出现在系统调用栈里,这样相当于采样的方式也能够发现耗时较多的函数。
虽说秒杀系统的瓶颈大部分在CPU,但这并不表示其他方面就一定不出现瓶颈。例如,如果海量请求涌过来,你的页面又比较大,那么网络就有可能出现瓶颈。
怎样简单地判断CPU是不是瓶颈呢?一个办法就是看当QPS达到极限时,你的服务器的CPU使用率是不是超过了95%,如果没有超过,那么表示CPU还有提升的空间,要么是有锁限制,要么是有过多的本地I/O等待发生。
现在你知道了优化哪些因素,又发现了瓶颈,那么接下来就要关注如何优化了。
3.5.2 如何优化系统
对Java系统来说,可以优化的地方很多,这里我重点说一下比较有效的几种手段,供你参考,它们是:减少编码、减少序列化、Java极致优化、并发读优化。接下来,我们分别来看一下。
- 减少编码
Java的编码运行比较慢,这是Java的一大硬伤。在很多场景下,只要涉及字符串的操作(如输入输出操作、I/O操作)都比较耗CPU资源,不管它是磁盘I/O还是网络I/O,因为都需要将字符转换成字节,而这个转换必须编码。
每个字符的编码都需要查表,而这种查表的操作非常耗资源,所以减少字符到字节或者相反的转换、减少字符编码会非常有成效。减少编码就可以大大提升性能。
那么如何才能减少编码呢?例如,网页输出是可以直接进行流输出的,即用resp.getOutputStream()函数写数据,把一些静态的数据提前转化成字节,等到真正往外写的时候再直接用OutputStream()函数写,就可以减少静态数据的编码转换。
我在《深入分析Java Web技术内幕》一书中介绍的"Velocity优化实践"一章的内容,就是基于把静态的字符串提前编码成字节并缓存,然后直接输出字节内容到页面,从而大大减少编码的性能消耗的,网页输出的性能比没有提前进行字符到字节转换时提升了30%左右。
- 减少序列化
序列化也是Java性能的一大天敌,减少Java中的序列化操作也能大大提升性能。又因为序列化往往是和编码同时发生的,所以减少序列化也就减少了编码。
序列化大部分是在RPC中发生的,因此避免或者减少RPC就可以减少序列化,当然当前的序列化协议也已经做了很多优化来提升性能。有一种新的方案,就是可以将多个关联性比较强的应用进行"合并部署",而减少不同应用之间的RPC也可以减少序列化的消耗。
所谓"合并部署",就是把两个原本在不同机器上的不同应用合并部署到一台机器上,当然不仅仅是部署在一台机器上,还要在同一个Tomcat容器中,且不能走本机的Socket,这样才能避免序列化的产生。
另外针对秒杀场景,我们还可以做得更极致一些,接下来我们来看第3点:Java极致优化。
- Java极致优化
Java和通用的Web服务器(如Nginx或Apache服务器)相比,在处理大并发的HTTP请求时要弱一点,所以一般我们都会对大流量的Web系统做静态化改造,让大部分请求和数据直接在Nginx服务器或者Web代理服务器(如Varnish、Squid等)上直接返回(这样可以减少数据的序列化与反序列化),而Java层只需处理少量数据的动态请求。针对这些请求,我们可以使用以下手段进行优化:
- 直接使用Servlet处理请求。避免使用传统的MVC框架,这样可以绕过一大堆复杂且用处不大的处理逻辑,节省1ms时间(具体取决于你对MVC框架的依赖程度)。
- 直接输出流数据。使用resp.getOutputStream()而不是resp.getWriter()函数,可以省掉一些不变字符数据的编码,从而提升性能;数据输出时推荐使用JSON而不是模板引擎(一般都是解释执行)来输出页面。
- 并发读优化
也许有读者会觉得这个问题很容易解决,无非就是放到Tair缓存里面。集中式缓存为了保证命中率一般都会采用一致性Hash,所以同一个key会落到同一台机器上。虽然单台缓存机器也能支撑30w/s的请求,但还是远不足以应对像"大秒"这种级别的热点商品。那么,该如何彻底解决单点的瓶颈呢?
答案是采用应用层的LocalCache,即在秒杀系统的单机上缓存商品相关的数据。
那么,又如何缓存(Cache)数据呢?你需要划分成动态数据和静态数据分别进行处理:
- 像商品中的"标题"和"描述"这些本身不变的数据,会在秒杀开始之前全量推送到秒杀机器上,并一直缓存到秒杀结束;
- 像库存这类动态数据,会采用"被动失效"的方式缓存一定时间(一般是数秒),失效后再去缓存拉取最新的数据。
你可能还会有疑问:像库存这种频繁更新的数据,一旦数据不一致,会不会导致超卖?
这就要用到前面介绍的读数据的分层校验原则了,读的场景可以允许一定的脏数据,因为这里的误判只会导致少量原本无库存的下单请求被误认为有库存,可以等到真正写数据时再保证最终的一致性,通过在数据的高可用性和一致性之间的平衡,来解决高并发的数据读取问题。
3.6 秒杀系统"减库存"设计的核心逻辑
如果要设计一套秒杀系统,那我想你的老板肯定会先对你说:千万不要超卖,这是大前提。
如果你第一次接触秒杀,那你可能还不太理解,库存100件就卖100件,在数据库里减到0就好了啊,这有什么麻烦的?是的,理论上是这样,但是具体到业务场景中,"减库存"就不是这么简单了。
例如,我们平常购物都是这样,看到喜欢的商品然后下单,但并不是每个下单请求你都最后付款了。你说系统是用户下单了就算这个商品卖出去了,还是等到用户真正付款了才算卖出了呢?这的确是个问题!
我们可以先根据减库存是发生在下单阶段还是付款阶段,把减库存做一下划分。
3.6.1 减库存有哪几种方式
在正常的电商平台购物场景中,用户的实际购买过程一般分为两步:下单和付款。你想买一台iPhone手机,在商品页面点了"立即购买"按钮,核对信息之后点击"提交订单",这一步称为下单操作。下单之后,你只有真正完成付款操作才能算真正购买,也就是俗话说的"落袋为安"。
那如果你是架构师,你会在哪个环节完成减库存的操作呢?总结来说,减库存操作一般有如下几个方式:
- 下单减库存,即当买家下单后,在商品的总库存中减去买家购买数量。下单减库存是最简单的减库存方式,也是控制最精确的一种,下单时直接通过数据库的事务机制控制商品库存,这样一定不会出现超卖的情况。但是你要知道,有些人下完单可能并不会付款。
- 付款减库存,即买家下单后,并不立即减库存,而是等到有用户付款后才真正减库存,否则库存一直保留给其他买家。但因为付款时才减库存,如果并发比较高,有可能出现买家下单后付不了款的情况,因为可能商品已经被其他人买走了。
- 预扣库存,这种方式相对复杂一些,买家下单后,库存为其保留一定的时间(如10分钟),超过这个时间,库存将会自动释放,释放后其他买家就可以继续购买。在买家付款前,系统会校验该订单的库存是否还有保留:如果没有保留,则再次尝试预扣;如果库存不足(也就是预扣失败)则不允许继续付款;如果预扣成功,则完成付款并实际地减去库存。
以上这几种减库存的方式都会存在一些问题,下面我们一起来看下。
3.6.2 减库存可能存在的问题
由于购物过程中存在两步或者多步的操作,因此在不同的操作步骤中减库存,就会存在一些可能被恶意买家利用的漏洞,例如发生恶意下单的情况。
假如我们采用"下单减库存"的方式,即用户下单后就减去库存,正常情况下,买家下单后付款的概率会很高,所以不会有太大问题。但是有一种场景例外,就是当卖家参加某个活动时,此时活动的有效时间是商品的黄金售卖时间,如果有竞争对手通过恶意下单的方式将该卖家的商品全部下单,让这款商品的库存减为零,那么这款商品就不能正常售卖了。要知道,这些恶意下单的人是不会真正付款的,这正是"下单减库存"方式的不足之处。
既然"下单减库存"可能导致恶意下单,从而影响卖家的商品销售,那么有没有办法解决呢?你可能会想,采用"付款减库存"的方式是不是就可以了?的确可以。但是,"付款减库存"又会导致另外一个问题:库存超卖。
假如有100件商品,就可能出现300人下单成功的情况,因为下单时不会减库存,所以也就可能出现下单成功数远远超过真正库存数的情况,这尤其会发生在做活动的热门商品上。这样一来,就会导致很多买家下单成功但是付不了款,买家的购物体验自然比较差。
可以看到,不管是"下单减库存"还是"付款减库存",都会导致商品库存不能完全和实际售卖情况对应起来的情况,看来要把商品准确地卖出去还真是不容易啊!
那么,既然"下单减库存"和"付款减库存"都有缺点,我们能否把两者相结合,将两次操作进行前后关联起来,下单时先预扣,在规定时间内不付款再释放库存,即采用"预扣库存"这种方式呢?
这种方案确实可以在一定程度上缓解上面的问题。但是否就彻底解决了呢?其实没有!针对恶意下单这种情况,虽然把有效的付款时间设置为10分钟,但是恶意买家完全可以在10分钟后再次下单,或者采用一次下单很多件的方式把库存减完。针对这种情况,解决办法还是要结合安全和反作弊的措施来制止。
例如,给经常下单不付款的买家进行识别打标(可以在被打标的买家下单时不减库存)、给某些类目设置最大购买件数(例如,参加活动的商品一人最多只能买3件),以及对重复下单不付款的操作进行次数限制等。
针对"库存超卖"这种情况,在10分钟时间内下单的数量仍然有可能超过库存数量,遇到这种情况我们只能区别对待:对普通的商品下单数量超过库存数量的情况,可以通过补货来解决;但是有些卖家完全不允许库存为负数的情况,那只能在买家付款时提示库存不足。
3.6.3 大型秒杀中如何减库存?
目前来看,业务系统中最常见的就是预扣库存方案,像你在买机票、买电影票时,下单后一般都有个"有效付款时间",超过这个时间订单自动释放,这都是典型的预扣库存方案。而具体到秒杀这个场景,应该采用哪种方案比较好呢?
由于参加秒杀的商品,一般都是"抢到就是赚到",所以成功下单后却不付款的情况比较少,再加上卖家对秒杀商品的库存有严格限制,所以秒杀商品采用"下单减库存"更加合理。另外,理论上由于"下单减库存"比"预扣库存"以及涉及第三方支付的"付款减库存"在逻辑上更为简单,所以性能上更占优势。
"下单减库存"在数据一致性上,主要就是保证大并发请求时库存数据不能为负数,也就是要保证数据库中的库存字段值不能为负数,一般我们有多种解决方案:一种是在应用程序中通过事务来判断,即保证减后库存不能为负数,否则就回滚;另一种办法是直接设置数据库的字段数据为无符号整数,这样减后库存字段值小于零时会直接执行SQL语句来报错;再有一种就是使用CASE WHEN判断语句,例如这样的SQL语句:
UPDATE item SET inventory = CASE WHEN inventory >= xxx THEN inventory-xxx ELSE inventory END
3.6.4 秒杀减库存的极致优化
在交易环节中,"库存"是个关键数据,也是个热点数据,因为交易的各个环节中都可能涉及对库存的查询。但是,我在前面介绍分层过滤时提到过,秒杀中并不需要对库存有精确的一致性读,把库存数据放到缓存(Cache)中,可以大大提升读性能。
解决大并发读问题,可以采用LocalCache(即在秒杀系统的单机上缓存商品相关的数据)和对数据进行分层过滤的方式,但是像减库存这种大并发写无论如何还是避免不了,这也是秒杀场景下最为核心的一个技术难题。
因此,这里我想专门来说一下秒杀场景下减库存的极致优化思路,包括如何在缓存中减库存以及如何在数据库中减库存。
秒杀商品和普通商品的减库存还是有些差异的,例如商品数量比较少,交易时间段也比较短,因此这里有一个大胆的假设,即能否把秒杀商品减库存直接放到缓存系统中实现,也就是直接在缓存中减库存或者在一个带有持久化功能的缓存系统(如Redis)中完成呢?
如果你的秒杀商品的减库存逻辑非常单一,比如没有复杂的SKU库存和总库存这种联动关系的话,我觉得完全可以。但是如果有比较复杂的减库存逻辑,或者需要使用事务,你还是必须在数据库中完成减库存。
由于MySQL存储数据的特点,同一数据在数据库里肯定是一行存储(MySQL),因此会有大量线程来竞争InnoDB行锁,而并发度越高时等待线程会越多,TPS(Transaction Per Second,即每秒处理的消息数)会下降,响应时间(RT)会上升,数据库的吞吐量就会严重受影响。
这就可能引发一个问题,就是单个热点商品会影响整个数据库的性能, 导致0.01%的商品影响99.99%的商品的售卖,这是我们不愿意看到的情况。一个解决思路是遵循前面介绍的原则进行隔离,把热点商品放到单独的热点库中。但是这无疑会带来维护上的麻烦,比如要做热点数据的动态迁移以及单独的数据库等。
而分离热点商品到单独的数据库还是没有解决并发锁的问题,我们应该怎么办呢?要解决并发锁的问题,有两种办法:
- 应用层做排队。按照商品维度设置队列顺序执行,这样能减少同一台机器对数据库同一行记录进行操作的并发度,同时也能控制单个商品占用数据库连接的数量,防止热点商品占用太多的数据库连接。
- 数据库层做排队。应用层只能做到单机的排队,但是应用机器数本身很多,这种排队方式控制并发的能力仍然有限,所以如果能在数据库层做全局排队是最理想的。阿里的数据库团队开发了针对这种MySQL的InnoDB层上的补丁程序(patch),可以在数据库层上对单行记录做到并发排队。
你可能有疑问了,排队和锁竞争不都是要等待吗,有啥区别?
如果熟悉MySQL的话,你会知道InnoDB内部的死锁检测,以及MySQL Server和InnoDB的切换会比较消耗性能,淘宝的MySQL核心团队还做了很多其他方面的优化,如COMMIT_ON_SUCCESS和ROLLBACK_ON_FAIL的补丁程序,配合在SQL里面加提示(hint),在事务里不需要等待应用层提交(COMMIT),而在数据执行完最后一条SQL后,直接根据TARGET_AFFECT_ROW的结果进行提交或回滚,可以减少网络等待时间(平均约0.7ms)。据我所知,目前阿里MySQL团队已经将包含这些补丁程序的MySQL开源。
另外,数据更新问题除了前面介绍的热点隔离和排队处理之外,还有些场景(如对商品的lastmodifytime字段的)更新会非常频繁,在某些场景下这些多条SQL是可以合并的,一定时间内只要执行最后一条SQL就行了,以便减少对数据库的更新操作。
持续更新中...