# 系统设计面试题
# 如何设计一个秒杀场景?
秒杀场景的核心特点是高并发、低库存、短时间爆发式访问 。因此,设计时需要解决以下几个问题:
- 高并发处理 :如何应对大量用户同时访问?
- 库存一致性 :如何保证库存不会超卖或少卖?
- 用户体验 :如何减少用户等待时间,避免页面崩溃?
- 防刷机制 :如何防止恶意用户利用脚本抢购商品?
面对上面这些问题,可以针对每一层做一些设计:

1、前端层:
- 静态资源分离 :将秒杀页面的静态资源(如HTML、CSS、JS)部署到CDN(内容分发网络),减轻服务器压力。
- 请求拦截:活动未开始时,前端按钮置灰;通过验证码、点击频率限制减少无效请求。不过前端逻辑可以被绕过,活动时间、购买资格这些校验必须在服务端再做一次。
2、网关层:
- 流量拦截 :使用API网关对请求进行初步过滤,例如IP限流、黑名单拦截等。
- 身份验证 :通过Token或签名验证用户身份,防止未登录用户直接访问秒杀接口。
3、缓存层:
- Redis缓存库存 :将商品库存信息存储在Redis中,利用其高性能特性处理库存扣减操作。
- 预热数据 :在秒杀活动开始前,将商品信息和库存数据加载到Redis中,减少数据库压力。
4、消息队列:
- 削峰填谷 :使用消息队列(如Kafka)将用户的秒杀请求异步化,避免直接冲击后端服务。
- 订单处理 :将成功的秒杀请求放入队列,由后台服务异步生成订单,提高系统吞吐量。
5、数据库层
- 条件扣减:通过
UPDATE ... SET stock = stock - 1 WHERE sku_id = ? AND stock > 0扣减库存,检查影响行数;同时把创建订单和扣减库存放在一个本地事务里。 - 读写分离 :商品介绍等允许短暂延迟的数据可以读从库;库存扣减和订单状态判断走主库,避免读到复制延迟下的旧数据。
关键的核心业务逻辑实现,库存防超卖方案采用:Redis原子操作 + 异步扣减数据库。

具体流程如下:
- 秒杀请求到达:用户发起秒杀请求,系统先校验身份、活动时间和购买资格,并为这次业务请求分配固定的请求 ID,后续重试沿用这个 ID。
- Redis库存预扣:通过 Lua 脚本把「是否重复抢购、库存是否充足、扣减库存、记录预占资格」放在一起执行。不能先
GET再DECR,因为单条命令原子,不代表这两条命令合起来也原子。如果使用 Redis Cluster,脚本涉及的 Key 要落在同一个槽里。 - 异步更新数据库:预扣成功后,可靠地记录待下单事件,再交给消息队列异步处理。此时返回的是「已拿到资格,订单处理中」,最终是否下单成功,要以订单落库结果为准。
- 后台服务消费消息:后台服务从消息队列中消费秒杀成功的消息,执行以下操作:
- 1、以请求 ID 做消费幂等,并通过「活动 ID + 用户 ID + 商品 ID」唯一约束兜底一人一单,重试时不能重新生成时间戳作为幂等键;
- 2、在同一个数据库事务中进行库存条件扣减和订单创建,库存不足就回滚事务;
- 3、事务提交后才确认消息,失败则重试;如果已经处理过,就返回已有订单,不能再扣一次库存。
- 最终一致性校验:在Redis库存扣减和数据库库存更新之间,可能会存在短暂的不一致状态。为了保证最终一致性,可以采取以下措施:
- 1、记录每个预占资格的处理状态,对长时间没有生成订单的请求进行核查和补偿。
- 2、对账时区分「正在排队的预占」「已生成的订单」和「已取消的订单」,不能直接拿数据库库存覆盖 Redis,否则可能把正在排队的库存重新卖出去。
- 3、只有确认订单没有成功、资格已经进入取消状态,才幂等地归还库存;超时但结果未知时,先查状态,不能直接回补。
回答参考
关于秒杀系统的设计,其实业界有一个非常经典的共识,那就是「层层漏斗」原则。
秒杀场景最大的特点就是「瞬时并发极高、读多写少、且绝对不能超卖」。比如 10 万个人抢 10 台手机,如果让这 10 万个请求直接打到 MySQL 数据库里,肯定瞬间就宕机了。所以我的大体设计思路是:在请求到达最终的数据库之前,设置多道防线,层层拦截,尽量把流量挡在上游。
具体落地的话,我会从以下三个关卡来汇报我的设计过程。不过在设计之前,我会先确认活动有多少商品、峰值有多高、是否限制一人一单,以及用户能不能接受排队。10 万个人抢同一个商品,和 10 万个人分散抢几千个商品,热点压力是不一样的。
第一关:前端与网关层的拦截(动静分离与防刷) 在这个阶段我们的目的是尽可能拦住「无效请求」和「机器脚本」。 首先,秒杀页面的所有图片、CSS等静态资源全部要上 CDN,做「动静分离」,商品状态查询和点击「抢购」等业务请求才走动态接口,状态查询也要控制轮询频率。 其次,网关层必须做限流和防刷。比如按用户、设备和 IP 综合限制请求频率,在高风险请求上加「滑块验证码」或「答题」。验证码可以提高脚本成本,也能让集中点击稍微打散,但不能单靠它防住所有黄牛;只按 IP 限流也可能误伤共用出口的正常用户,阈值要结合业务和压测调整。

第二关:真正的秒杀核心,Redis 预扣库存(抗压与防超卖) 经过第一关,假设还有 1 万个有效请求进来了,这时候不能让每个请求都去查 MySQL,活动库存要提前预热进 Redis。这里的重点是如何防止超卖。很多人以为查一下 Redis 还有库存,然后再减去 1 就可以了。但在高并发下,这「一查一减」之间,别人可能插队进来了,导致 10 件商品发出了 11 个资格。为了解决这个问题,我会用 Redis 组合 Lua 脚本,把「查库存、校验一人一单、扣库存、记录资格」捆绑在一起执行。库存为 0 后,后面的请求直接返回「当前库存已被抢完」。这能解决正常运行时的并发争抢,但 Redis 故障切换仍可能丢失部分已确认写入,所以最终还要靠数据库的库存条件扣减和唯一约束兜底。

第三关:消息队列的异步下单(保护数据库兜底) 好了,现在 Redis 里刚刚成功预扣了 10 个库存。如果活动只有这一个商品,数据库处理这 10 笔订单本身并不难;但如果同时有很多商品参加活动,或者下单还要调用多个服务,我会引入 **消息队列(MQ)**做异步削峰。资格受理后,前端收到的是「已抢到资格,订单处理中」,再通过带退避的轮询查询结果。在这背后,订单服务根据数据库能承受的速度消费消息,在 MySQL 里完成库存扣减和订单创建。这就是「异步削峰填谷」。不过队列也不能无限排,要监控积压量和排队时间,处理不过来时及时限制新请求。

这里还有一个很容易被追问的细节:Redis 扣成功了,但 MQ 没发出去怎么办? 这两个操作分属不同系统,不能靠「先扣再发」就认为它们一起成功了。我会给预占资格配套一个可恢复的待处理事件记录。例如,在同一个 Lua 脚本里记录资格并写入同槽的 Redis Stream,再由后台转发到 MQ,转发失败就重试,下单消费靠请求 ID 去重。这里仍然要考虑 Redis 持久化和故障切换的数据丢失风险,不能把 Stream 当成绝对不丢的保证;对资格可靠性要求更高时,可以在入口过滤后使用数据库预占记录和事务消息表作为可靠依据。

另外,Lua 的原子执行指的是执行期间不会插入其他命令,不代表脚本报错后会自动回滚前面的写入。所以脚本要先检查参数和数据类型,再执行写操作,并为异常状态保留核查、补偿的路径。
总结一下我的整体链路: 用动静分离、限流和防刷挡住大部分流量;用 Redis + Lua 原子预占资格;用可靠事件记录和 MQ异步下单;最后让 MySQL 条件扣减、事务和唯一约束兜底,再配上幂等补偿和订单超时释放。既要讲正常流程,也要讲失败以后怎么收场。
# 让你设计一个 RPC 框架,怎么设计?
其实这就相当于要我们亲手去造一个类似 Dubbo 或者 gRPC 的轮子。
我是这样来思考和设计的,设计的核心目标只有一个:让业务开发人员调用远端服务时,就像调用本地方法一样傻瓜、简单,完全不需要去关心底层网络的那些脏活累活。
顺着一条请求从发出到接收的完整路程,我会把这个框架拆分成以下几个核心的模块来逐步设计:
第一步是要解决「无感调用」的问题,这里需要引入:动态代理模式。 在代码里,客户端明明只是调了一个接口 userService.getUser(id),它怎么就跑到另一台机器上去了呢?这就需要RPC框架在底层用动态代理(比如 JDK 自带的,或者 CGLIB)偷偷生成一个代理对象。这个代理会把用户的调用给「拦截」下来,把我要调哪个类、哪个方法、传了什么参数这些信息统统收集起来。这就是整个RPC真正的入口。

第二步是数据打包,这里需要设计:序列化与通信协议模块。 收集到参数后,网络线上是不能传输Java对象的,只能传二进制的0101字节流。所以我们需要引入序列化框架(比如 Protobuf、Hessian 或者 JSON),把对象编码成字节数组。序列化和压缩是两件事,是否压缩要根据消息大小和 CPU 开销单独决定。 但光有字节数据还不够,由于底层TCP传输是像水流一样的,如果我们连续发两个请求,接收端很容易黏在一起分不清谁是谁(这就是著名的TCP粘包/半包问题)。为了解决这个问题,我还要设计一个自定义协议头。在这个协议头里,我会明确写上「消息体有多长」,接收端凑齐一帧以后再解码。除此之外,还会放上魔数、协议版本、序列化类型、消息类型和请求 ID,并限制最大帧长度,避免异常报文占用过多内存。

第三步是负责数据的高效搬运,也就是:网络通信底座。 数据准备好了,怎么发出去?如果是 Java 技术栈、需要处理大量连接,我会引入 Netty 来作为底层通信框架,用 NIO 模型管理连接,并复用长连接,避免每次调用都重新握手。不过业务方法不能直接在 Netty 的 IO 线程里执行,要交给有界的业务线程池;线程池满了就快速拒绝,不能让慢业务把收发消息的线程一起拖住。

第四步是解决找路的问题,需要设计:服务注册与发现模块。 数据再好,不知道往哪台服务器发也是白搭。所以框架里必须对接一个注册中心(比如 Zookeeper、Nacos 或者 Redis)。 服务端一旦启动,就把自己的 IP 和端口挂载到注册中心;客户端启动时,就从注册中心拉取一份可用服务器的地址列表,缓存在本地内存里,再通过订阅通知或定期刷新更新它。服务端通过心跳或租约维持注册,宕机后要及时摘除。注册中心暂时不可用时,客户端可以继续使用已缓存且可达的节点,同时结合调用失败剔除失效地址。

第五步是保障系统的稳定性,加入:负载均衡与容错机制。 客户端拿到了一大把服务端的 IP,总不能每次都选第一个吧?这时候就需要设计负载均衡模块,用比如「轮询」、「随机」或者「一致性哈希」的算法挑一台机器出来。 如果挑的这台机器刚好网络闪断、调用失败了怎么办?所以最后还要包装一层容错策略,比如对幂等查询配置有限次数的故障重试(Failover),对不适合重试的调用快速失败(Failfast)。这里一定要注意,超时只代表客户端没有按时拿到响应,服务端可能已经执行成功了。所以扣款、创建订单这类操作,只有业务提供了幂等保证才可以重试,而且重试要受同一个总超时预算限制,不能每换一台机器就重新获得完整的等待时间。

第六步是把调用真正闭环:响应关联和服务端执行。 一条长连接上可能同时跑着很多请求,响应回来也未必按发送顺序排列。所以客户端会维护一个 requestId -> Future 的映射,收到响应后按请求 ID 唤醒对应的等待者。超时就移除映射并释放资源,晚到的响应不能再把已经超时的调用当成成功。服务端则负责解码、反序列化、查找具体的服务和方法,在业务线程池里执行后,再把结果或业务异常按原请求 ID 返回。

最后,框架还要能看得见问题。我会让请求携带 TraceID,记录调用耗时、超时率、重试次数和线程池排队情况,再配上连接检测、鉴权和优雅停机。否则接口确实能调通,但一到线上出了问题,很难判断到底是网络慢、排队慢,还是业务本身慢。
最后我想补充一点进阶的设计思路:扩展性。 一个好的RPC框架是不能把代码写死的,所以在设计底层结构时,我会大量运用 SPI 机制(服务提供者接口),把它做成「插件化」的。比如未来业务想把序列化方式从 JSON 换成 Protobuf,或者想增加新的负载均衡算法,业务人员只需要写个实现类,加个配置文件就行了,一点都不需要修改我 RPC 框架的核心源码。

总结一下: 用动态代理收集调用信息,经序列化和协议编码,通过 Netty 发往从注册中心发现、经过负载均衡选择的服务端;服务端执行后返回结果,客户端按请求 ID找到对应调用,再配上超时、幂等重试和监控。既要把请求送过去,也要把结果正确送回来。
# 让你设计一个短链系统,怎么设计?
关于短链系统的设计,这个系统在微博、营销短信里非常常见。它的核心需求其实就两件事:一是把长长的网址变成短短的字符串,二是当用户点击短网址时,能精准地跳回原来的长链接。
如果让我来从头设计这个系统,我会沿着整个请求的生命周期,按下面这几个核心模块去展开:
第一步是从业务上先解决「是怎么跳转的?」这个核心原理。 很多人可能觉得这是个啥黑科技,其实不管长链短链,它利用的纯粹就是 HTTP 协议里的重定向机制。当用户浏览器访问短链时,我们的服务器去查到对应的长链,然后给浏览器返回一个特定的状态码和一个 Location 响应头,浏览器就会自动跳往真实地址。

这里有个特别经典的考点:如果需要统计每次访问,或者允许修改跳转目标,我一般选择 302,并明确控制缓存。 301 是「永久重定向」,浏览器或代理可能缓存跳转结果,后续访问就绕过短链服务了。302 是「临时重定向」,更适合这种业务,但它也不是天然完全不缓存,所以我还会配上 Cache-Control: no-store,并检查 CDN 的缓存规则。如果链接永久不变,也不要求每次请求都回源统计,301 同样可以用,关键是按业务需求选择。
第二步也是最硬核的环节:怎么生成这个短链? 短链一般就是类似于像 t.cn/XyA1b2 这种后缀大概包含 6 位随机字符(数字+大小写字母)的短串。 很多人第一时间想到用 MD5 等哈希算法对长链接做哈希处理。这种方案需要处理哈希冲突。哈希值截断成 6 位以后,不能再保证唯一。它并不是不能用,只是必须用数据库唯一索引检测冲突,冲突后重新生成或加盐重试,不能发现短码已存在就把别人的长链覆盖掉。

如果没有随机短码的要求,我更倾向于「分布式 ID 发号器 + Base62 转换」方案,唯一性更容易解释清楚:
- 先利用咱们常说的分布式 ID 生成机制(比如 MySQL号段模式 或者 Redis自增),给每一个新发来的长链接,分配一个全局唯一的数字 ID(比如 10086)。发号记录不能在重启或故障切换后回退;如果使用 Redis 自增,要特别考虑持久化和异步复制丢失计数的问题,不能只凭
INCR是原子命令就承诺全局永不重复。 - 把这个十进制的数字ID,用类似数学里「十进制转二进制」的除法取余逻辑,转换成 62进制。为什么是62?因为 0-9、a-z、A-Z 加起来刚好62个字符。 您可别小看这62进制,哪怕只生成 6位长度的短链,62 的 6 次方大约是 568 亿种组合。不过这只是编码空间,实际够用多久要根据每天新增多少条链接来估算。Base62 本身不会把不同 ID 编成相同短码,但短码长度由 ID 大小决定:超过 6 位的范围就要扩展长度,不能硬截断。像常见的 64 位雪花 ID,直接转换后通常就不止 6 位了。
第三步是系统落地:怎么做存储和抗并发? 短链系统是个典型的「读多写少」的系统。写操作就是生成短链,存在 MySQL 里就行,基本映射是一列存短码、一列存原始长链,并给短码加唯一索引,业务元数据再按需求补充。 但是「读操作」的并发往往极其恐怖,比如一条热点营销短信发出去,瞬间几百万点击进来查短链。所以绝对不能让请求直接打到 MySQL 上。 我们必须引入 Redis做缓存。用户拿短链来访问,先去 Redis 查映射关系,查到了直接拼装 302 响应返回。查不到,再去 MySQL 捞一把,捞到了立刻写进 Redis 备用。这样系统的吞吐量就能上得去。

最后,考虑一下系统的健壮性和兜底(高级防范)。 如果是对外的公众短链服务,一定会遇到黑客恶意攻击,比如一直瞎传不存在的短链过来,试图绕过 Redis 直接把咱们的 MySQL 查穿(这就是典型的「缓存穿透」)。为了防范这一点,我会引入 布隆过滤器(Bloom Filter) 把所有生成过的短链存起来,或者在 Redis 里对查不到的废弃短链存一个极短时间的「空值」,直接在缓存层就把恶意请求挡回去。同时在网关层按用户和 IP 限流。过滤器也要随新链接发布同步更新;如果过滤器还没加载完成或者增量同步落后,不能直接用「过滤器里没有」拒绝所有请求,否则正常的新短链也会被误拦截。

再补充几个业务上很容易被忽略的细节。 第一个是「同一个长链接,要不要返回同一个短链?」我会先确认需求。同一个地址可能属于不同用户、不同营销活动,还可能设置不同的有效期,所以不能只按长链全局去重。如果需要去重,可以按租户、活动和长链建立业务去重规则,生成接口的重试则使用固定的请求 ID,避免一次操作创建多条记录。
第二个是「链接失效了怎么办?」映射表不能只有两列,还要有创建时间、过期时间和启用状态。缓存有效期不能超过业务有效期,访问时也要校验业务到期时间;禁用或修改目标后要让缓存及时失效。短码一般不重新分配给其他链接,否则一条很多年前的短信,可能突然跳到完全不相关的网站。
第三个是「点击统计怎么做?」跳转链路要尽量短,访问事件异步写入 MQ,再做 PV、UV 和来源统计。PV 和 UV 的口径要说清楚,爬虫和短信预览也可能请求短链,不能把所有请求都算成真实用户点击。热点链接可以加本地缓存和请求合并,统计链路慢了也不能把跳转接口一起拖慢。
最后,递增 ID 转成的短码可以被枚举,短码不能代替访问权限校验。如果业务要求难以猜测,可以采用足够长的安全随机短码,再用唯一索引处理碰撞;生成接口还要校验目标地址的协议,限制为允许的 HTTP/HTTPS 地址,并配上恶意链接审核和下架能力。
总结一下我的设计思路: 整个流转过程就是:用户请求转换长链 -> 通过分布式ID发号器拿一个唯一数字 -> 借用 Base62 转成短码 -> 先持久化到 MySQL,再按需缓存到 Redis -> 用户点击短链 -> 命中 Redis 缓存 -> 返回 HTTP 302 状态码完成重定向跳转。再根据业务补上过期控制、缓存保护和异步统计,整个设计才算闭环。
# 如何设计一个点赞系统?
点赞这个功能看似特别简单,就点一下亮了,再点一下灭了。但在稍微大一点的互联网平台,它其实是一个典型的「高频读写」场景。一篇文章发布后,很多人会同时点赞、取消点赞,而每个进来刷文章的用户,系统还得告诉他「这篇文章有多少赞」以及「你有没有点过赞」。
所以我会先把需求分开:点赞状态要正确,重复点不能重复计数;总赞数通常可以接受短暂延迟;操作记录要能持久化和恢复。 流量不大时,MySQL 明细表加唯一索引、Redis 缓存就够用了。如果面试官给的是高并发场景,我会再展开下面这几个步骤。
第一步是核心:在 Redis 里怎么存点赞状态? 对于某篇文章,可以用 **Set(集合)**记录已经点赞的用户。比如文章 ID 是 10086,就建一个 article:likes:10086 的 Set。

- 用户 A 点赞,用
SADD把他的 UserID 放进去;已经存在就不会重复添加。 - 用户 A 取消点赞,用
SREM把他的 UserID 移出来;本来就不存在也不会重复扣减。 - 页面要判断按钮是否高亮,用
SISMEMBER查询这个用户是否在集合里。 - 如果集合保存了完整的点赞用户,
SCARD就是总赞数。如果 Redis 只保存部分热点状态,集合大小就不能当成全站准确计数。
如果把总数单独放在 String 里,我会用 Lua 把「修改状态」和「修改计数」一起执行。只有 SADD 确实新增了成员才加 1,只有 SREM 确实删掉了成员才减 1,不能用户发一次请求就无条件 INCR 或 DECR。在 Redis Cluster 里,这些需要一起操作的 Key 要放在同一个槽中。
这里还有个接口设计的小细节:我会让客户端明确发送「设置为已点赞」或「设置为未点赞」,而不是发送「切换一次」。因为网络重试很常见,同一个「切换」请求执行两次,用户本来想点赞,结果就变成取消点赞了。
第二步是持久化:数据怎么回到 MySQL 里兜底? 高并发下,可以用 Redis 承接实时状态和计数,再通过 MQ 异步落库。数据库里按「内容 ID + 用户 ID」建立唯一约束,保存当前状态和版本号,总数则作为可以从明细重建的汇总数据。

不过不能只写一句「Redis 更新成功后发条消息」就结束。万一进程在这两个动作中间崩溃,用户已经看到点赞成功,数据库却永远收不到消息。我会为状态变更保留可恢复的事件记录,比如在 Lua 中一起写入同槽的 Redis Stream,再由后台可靠转发到 MQ,并处理转发重试和消费幂等。这个方案仍然受 Redis 持久化、复制和故障切换的可靠性限制。
如果业务要求确认成功的点赞不能因为 Redis 故障而丢失,我更倾向于先在 MySQL 本地事务里更新点赞明细并写入事务消息表,再异步更新 Redis 和汇总计数。是否采用 Redis 优先的异步方案,要看流量和允许丢失数据的范围,不能把所有业务都套成一个答案。
第三步是最容易被追问的地方:重复消息和乱序怎么处理? 比如用户刚点赞,马上又取消,结果「取消」消息先落库,「点赞」消息后落库。如果只按到达顺序更新,最后数据库反而变成了已点赞。
所以事件里要有固定的事件 ID,以及同一「用户 + 内容」下递增的状态版本。这个版本在状态真正变更时由服务端生成,不能只拿客户端时间戳来代替。消费者只接受比当前版本更新的状态;重复消息、旧消息直接忽略。明细状态和相关的计数变更要在事务里处理,只有状态确实发生变化才调整计数。重建缓存时也要恢复版本基线,不能版本归零后让旧消息重新生效。

第四步是热点防范:大 V 发帖,单个 Key 被打爆怎么办? 查询总数的流量,可以用短时间的 本地缓存挡在 Redis 前面,允许页面显示的总数晚一两秒更新。用户自己的点赞状态仍然按状态存储查询,不能拿一份公共缓存替所有用户判断。

如果写请求也集中在一个特别大的 Set 上,我会按 userId 哈希把它拆成多个桶,每个桶维护自己的状态和计数,读取总数时再汇总,并缓存汇总结果。桶可以分散到不同 Redis 节点,但每个桶内需要原子更新的 Key 仍然要同槽。数据库汇总也可以按桶更新,避免所有消费事务争抢同一条计数记录。不能只是把点赞增量攒在 JVM 内存里几秒再写,因为进程一崩,这些还没持久化的操作就丢了。

总结一下我的设计逻辑: 点赞状态靠 Set 或明细表去重,状态和计数一起变更;高并发时用 Redis 和 MQ承接、削峰,但必须补上可靠事件记录、消费幂等和版本控制;热点总数用本地缓存,热点写入按用户拆桶。最终计数可以延迟,但用户状态和已确认操作的可靠性要按业务要求兜住。
# 让你设计一个分布式 ID 发号器,怎么设计?
我们在设计这个东西的时候,必须先明确它要达成什么目标:首先肯定得是全局唯一的;其次,生成的 ID 最好是「趋势递增」的(一般是全数字的 long 类型),因为这些 ID 最终大多数是要作为数据库主键的,如果乱序插入,会引发 MySQL 底层 B+ 树索引页的频繁分裂,导致性能剧烈下降。(随机 UUID 作为聚簇索引主键时,确实会增加存储和随机写入成本;不过有序 UUID 也是一种选择,不能把所有 UUID 一概排除。)
围绕着「全局唯一、趋势递增、高性能」这三点,如果让我来设计,我会提供两种业内最成熟的架构方案来应对不同的场景:
第一种方案,追求极致性能与去中心化:雪花算法(Snowflake)。 这其实是把计算逻辑全放在服务器本地的一种设计。我会把一个 64 位的数字像「切蛋糕」一样划分出不同的职能区间:
- 最高位 1 位空着不用。
- 接着 41 位用来存时间戳(相对于自定义起始时间的毫秒差),41 位大约能覆盖 69 年,这也让 ID 整体上随着时间趋势递增。
- 接着 10 位用来存机器的专属 WorkerID,这样哪怕多台机器同一毫秒在并发,也能保证生成的范围互不干扰。
- 最后 12 位留给流水号,万一这台机器在同一毫秒内来了好几个请求,就靠这 12 位做本地的原子递增(最多一毫秒能生成 4096 个)。
这种设计的最大优势是纯本地内存计算,没有任何网络 IO,发号速度快得惊人。 但它有个著名的隐患叫「时钟回拨」,因为它极度依赖服务器本地时间。如果系统校时让时间倒退,系统就可能回到已经用过的时间区间,生成重复 ID。我会记录上次发号的时间:回拨很小就有界等待,回拨太大就暂停发号并告警,或者使用经过设计的逻辑时钟方案,不能一直自旋耗 CPU。如果切换备用发号器,必须保证它和原方案的 ID 空间不重叠,不能随便换个算法就继续发。

雪花算法还有两个工程细节。 一个是 WorkerID 不能重复,不能简单拿容器 IP 的最后几位来凑。可以通过协调服务分配,并明确实例退出、租约失效和编号重新使用的规则,防止旧实例还在发号,新实例就用了同一个编号。实例重启或编号重新分配时,也要避免在已用过的时间区间里从序列号 0 重新开始,可以结合持久化时间水位或安全的等待规则处理。另一个是同一毫秒的 4096 个序列号用完后,要等到下一个可用毫秒,不能把序列号归零后接着发。

另外,「趋势递增」不等于「所有机器严格按请求顺序递增」。不同机器的时钟有差异,同一毫秒内还有 WorkerID 的影响。如果业务需要的是严格全局顺序,比如某个流水序列,就要单独设计协调和串行发号,不能只靠雪花算法。
第二种方案,降低数据库访问频率、减少时钟依赖:数据库号段模式(类似美团 Leaf 的核心思想)。 如果业务对「时钟回拨」零容忍,我就会设计一个中心化的发号服务。 一说到用数据库发号,大家直觉就是「每次来要一个 ID 就去查一次数据库,并发一高数据库就挂了」。所以「号段模式」对它进行了极其巧妙的改良,核心思想改成了「批发」。

简单来说:
- 当业务服务器来请求 ID 时,发号器不给 1 个,而是直接给一个「号段」的范围,比如
1 ~ 1000。 - 业务机器拿到这 1000 个号码后,就把它们缓存在本地内存里。接下来 1000 次用户请求,全部从应用内存里通过原子加(
AtomicLong)悄悄分发掉,根本不需要经过网络。 - 那快用完了怎么办? 这里我会设计一个「双 Buffer 机制」。当第一批号码剩余量降到预设阈值时,异步开启一个后台线程,提前去发号库把下一批号段(比如
1001 ~ 2000)预先加载到内存里备用。
号段分配本身也要保证原子性,比如在事务里锁定对应业务的发号记录,把 max_id 增加一个步长,再把这段范围交给实例。多个实例领到的范围不能重叠,双 Buffer 的切换也要做好线程安全。
这种设计可以在数据库短暂不可用时继续使用已经领取的号码,但能撑多久取决于剩余号段和发号速度,不是数据库宕机多久都没关系。号段耗尽就只能等待恢复或明确失败。另外,实例崩溃后没用完的号码通常直接作废,不再回收;不同实例领取不同号段,也不保证全局严格递增,所以号段模式同样会有空洞和乱序。
总结一下我的思路: 如果追求本地生成、低延迟,我会选择处理好时钟回拨和 WorkerID 分配的雪花算法;如果已有可靠数据库,希望方便运营、尽量减少对机器时钟的依赖,我会选择带双 Buffer 的数据库号段模式。两种方案都要监控重复风险、发号延迟和可用容量,而且都不能直接承诺「全局严格递增、连续无空洞」。这些要求如果业务确实需要,就要另外设计。
# 订单到了半个小时,半个小时未支付就取消怎么实现?
有多种实现订单超时自动取消的技术方案,包括定时轮询、JDK的延迟队列、时间轮算法、Redis实现以及MQ消息队列中的延迟队列和死信队列。
定时轮询:基于SpringBoot的Scheduled实现,通过定时任务扫描数据库中的订单。优点是实现简单直接,但缺点是会给数据库带来持续压力,处理效率受任务执行间隔影响较大,且在高并发场景下可能引发并发问题和资源浪费。
JDK的延迟队列(DelayQueue):基于优先级队列实现,减少数据库访问,提供高效的任务处理。优点是内部数据结构高效,线程安全。缺点是所有待处理订单需保留在内存中,可能导致内存消耗大,且无持久化机制,系统崩溃时可能丢失数据。
时间轮算法:通过时间轮结构调度大量定时任务,执行效率高,但触发精度受时间轮刻度和调度延迟影响,并不是绝对准时。优点是实现简单,执行效率高,且有成熟实现库。缺点同样是内存占用和崩溃时数据丢失的问题。

Redis实现:
有序集合(Sorted Set):以订单截止时间作为 score,后台分批查找已经到期的订单。多个执行器需要原子领取任务,并保留处理中状态或租约,失败后可以重新领取;不能取出后直接删除,执行器一崩就把任务丢了。它能减少数据库扫描,但仍要处理 Redis 的可靠性、任务补偿和订单状态一致性。
Key过期监听:利用 Redis 键过期事件触发检查,实现比较方便,但不能单独作为可靠关单方案。通知是在 Key 实际被删除时产生,不保证 TTL 一到就立刻触发;而且底层是 Pub/Sub,消费者断线期间的事件不会补发。它可以作为辅助触发,不能替代持久化任务和兜底扫描。

MQ消息队列:
延迟消息:使用所选 MQ 支持的延迟投递能力,在指定时间附近把消息交给消费者。它便于异步处理和扩展,但要确认支持的最长延迟、时间精度、持久化和故障恢复语义;消息到期也可能因为消费者积压而晚处理,所以仍需兜底扫描。
TTL + 死信队列:订单创建后,向不配置普通消费者的等待队列发送消息,设置消息 TTL,并配置死信交换机,消息过期后转入关单队列,由消费者处理。这里是消息过期,不是队列自身过期。同一个队列混放不同 TTL 的消息时,短延迟消息可能被队头的长延迟消息挡住,所以固定 30 分钟的业务可以统一 TTL,不同超时时长则要分开设计。

不同方案各有优劣,实际应用中应根据系统的具体需求、资源状况以及技术栈等因素综合评估,选择最适合的方案。在许多现代大型系统中,通常会选择消息队列的延迟队列或死信队列方案,以充分利用其异步处理、资源优化和扩展性方面的优势。
回答参考
这个问题我会先抓住两个重点:怎么在到期后触发检查,以及怎么保证只取消真正未支付的订单。 延迟消息只是个闹钟,决定订单能不能关掉的,还是数据库里的状态和支付结果。
第一步,创建订单时就把截止时间保存下来。 比如 expire_time = 创建时间 + 30 分钟,同时在本地事务里写入一条待发送的关单事件,再由后台把它投递到支持延迟投递的 MQ。消息里带上订单 ID 和截止时间,发送失败可以重试。如果事件晚了几分钟才发出去,就按距离截止时间的剩余时长设置延迟,不能又重新等完整的 30 分钟。已经到期就立即触发检查。
第二步,消息到期后,先判断再关单。 消费者查主库,订单已经支付或取消,就直接幂等返回;还没到截止时间,就重新安排检查。到期且符合关闭条件,才尝试把状态从「待支付」改成「已取消」,比如:
UPDATE orders
SET status = 'CANCELLED'
WHERE id = ?
AND status = 'UNPAID'
AND expire_time <= CURRENT_TIMESTAMP;
我会检查影响行数,只有成功改了这一次状态,才在同一事务里记录库存释放事件。支付回调也只能按允许的状态转换更新订单,这样不会出现关单线程查到「未支付」、支付线程刚写入成功,关单线程又把已支付状态覆盖掉的情况。
第三步,处理支付平台和订单库之间的时间差。 本地显示待支付,不一定代表用户没付钱,可能只是支付回调还没到。所以有关联支付单时,要按支付渠道的规则查询或关闭支付单,确认可以关闭再取消本地订单。如果查询超时、结果未知,我会保留待确认状态并重试,不会直接释放库存。支付和关单真正撞在一起、出现已扣款但订单不能履约的情况,要有对账和幂等退款流程收尾。

第四步,释放库存和兜底补偿。 库存服务消费释放事件时,以订单 ID 做幂等,不能同一条关单消息来两次就归还两次库存。如果库存和订单就在同一个数据库,也可以在关单事务里一起处理。除此之外,我会配一个低频扫描任务,按 status + expire_time 索引分批扫描超时订单,补上消息投递失败、消费积压等情况,并监控关单延迟和异常订单量。
如果项目没有 MQ、订单量也不大,直接用带索引和分批处理的数据库扫描也很实用。我的选择通常是:已有 MQ 就用延迟消息及时触发,数据库扫描兜底;最终靠状态条件更新防并发,靠支付对账和库存幂等完成闭环。
# 如果你的系统的QPS突然提升10倍你会怎么设计?
如果真的遇到这种情况,我会从「紧急止血(保命)」和「架构全面演进(治本)」两个阶段来设计和应对。
不过我不会一听到「涨了 10 倍」就马上决定分库分表。我会先确认是哪个接口涨了、流量持续多久、读写比例有没有变化,是正常活动还是异常重试。同时看 P95/P99 响应时间、错误率、CPU、线程池和连接池排队、Redis 延迟、数据库慢查询、MQ 积压,找到真正卡住的地方。应用 CPU 很空但数据库连接池已经满了,再扩应用实例反而可能把数据库压得更狠。
首先是第一阶段:紧急应对,核心原则是「弃车保帅,保证系统不挂」。 如果是突发流量,系统可能瞬间被打死,所以第一时间一定是限流和降级。 我们可以利用像 Sentinel 这样的工具,在网关层按压测容量设置请求限流阈值,超过系统承载能力的请求直接快速失败,返回「系统繁忙」的提示。即使损失了一部分用户的体验,也绝对比整个系统崩溃、所有人都不能用要强。 另外就是业务降级,比如在电商场景下,我会立刻关掉像「猜你喜欢」、评论显示、积分发放这些非核心业务,把所有的服务器 CPU 和内存资源全让给「下单」和「支付」这两个最核心的动作。如果公司的基建完善,我会对瓶颈所在的无状态服务进行弹性扩容。不过容器调度、缓存预热都需要时间,还要控制新实例带来的数据库连接总量;数据库或下游已经饱和时,不能只靠加应用实例。

接下来是第二阶段:如果这 10 倍 QPS 变成了常态,就要围绕实际瓶颈做长期容量改造。 这就需要把系统内部拆开来看,把读流量和写流量分开治理。
- 第一,针对读请求,优先考虑「减少重复查询和多级缓存」。 如果瓶颈来自大量重复读取,继续把所有请求交给 MySQL 就不合适,Redis 也可能遇到热点或带宽瓶颈。这时我会考虑「本地缓存(比如 Caffeine 或 Guava) + Redis 分布式系统缓存」的双层架构。 把那些极少变化的热点数据,直接加载到应用服务器自己的 JVM 内存里。这样大量的读请求连网络请求都不用发,在服务器本地就直接拿到数据返回了,系统的响应时间会被压到极致,也能极大地保护后端的 Redis 和 MySQL。

第二,针对允许异步处理的写请求,核心思路是「排队削峰」。 比如下单资格已经预占,或者点赞总数允许延迟汇总,我会引入 消息队列(MQ)。请求可靠受理后,前端拿到请求 ID 和「处理中」状态,再查询最终结果。后台根据数据库能承受的速度消费。对于必须同步确认结果的扣款、余额修改,不能把消息入队直接当成业务成功,要另行优化事务、锁竞争和分片。这就像游乐场门口的蛇形排队通道一样,把瞬间爆发的流量洪峰,变成了平平稳稳的细水长流。

这里还要强调一点:MQ 能削掉短时洪峰,不能凭空增加数据库的长期处理能力。 假设每秒进来 1 万条消息,数据库每秒只能处理 2000 条,队列每秒就净增 8000 条。流量如果一直这么高,排队时间只会越来越长。所以要给积压和等待时间设上限,配上入口反压;确认消费端或存储是长期瓶颈以后,再优化批量写入、增加有效的消费并行度或扩展存储。

- 第三,数据库底层的终极改造。 如果数据库仍然是瓶颈,我会先看索引、SQL、事务长度、锁等待和连接池配置,确认不是慢查询或长事务造成的。如果优化后单库容量或吞吐仍然不够,再考虑「读写分离」和「分库分表」。用主库抗写压力,多台从库抗读压力;把原来的一张上亿级的大表,按规律拆分成 100 张小表,分散到不同的机器上,增加处理能力。不过要先选好分片键,考虑热点、跨分片查询、事务和后续扩容,不能认为拆成 100 张表就一定获得 100 倍吞吐。

总结一下我的完整思路就是: 先根据监控确定瓶颈,用限流、降级和针对性扩容稳住系统;重复读用缓存和查询优化,允许异步的写用 MQ 削峰,持续超出容量时再扩展消费和存储。最后用接近真实流量分布的压测验证吞吐、P99 延迟和失败率,把容量水位、告警和扩容预案一起补齐,不能只凭「用了哪些组件」判断设计成功。
# 如果做一个大流量的网站,单Redis无法承压了如何解决?
这个问题我会先判断:到底是数据太多放不下,所有 Key 的请求都很多,还是只有某一个热点 Key 被打爆了? 这三种问题看起来都是「Redis 扛不住」,但解决方法不一样,不能一上来就说加从节点或者上 Cluster。
第一步,先确认有没有可以直接消掉的开销。 我会看 Redis 的 CPU、内存、网络带宽、慢命令和客户端连接情况。如果在请求链路里用了 KEYS、读了一个特别大的集合,或者 Lua 脚本执行很久,先解决这些问题。大集合分页读取、清理大 Key 时考虑异步释放;连续多个独立命令可以按需用 Pipeline 减少往返,但 Pipeline 本身不保证原子性,还要限制批次大小。
第二步,如果主要是读流量高,可以用缓存和读副本分担。 对变化不频繁的热点配置、商品介绍,在应用里加 Caffeine 这类本地缓存,大量请求就不必再经过 Redis。如果业务允许读到短暂的旧数据,也可以把一部分读请求分配给从节点。

不过这里要区分清楚,主从复制不会把主节点的写压力分散掉,Sentinel 主要解决故障切换,也不等于数据分片。而且复制有延迟,库存校验、分布式锁和刚修改就必须立即读到的状态,不能随便交给从节点。客户端也要明确配置读路由,不是部署了副本就自动分担所有读取。
第三步,如果数据量和整体读写都超过单节点容量,可以上 Redis Cluster。 Cluster 把 Key 映射到 16384 个槽,再把槽分配给不同的主节点,多个主节点各自处理一部分数据和请求。普通 Key 的槽位可以理解为 CRC16(key) % 16384;Key 使用 Hash Tag 时,则按花括号内的内容计算槽位。

这样能横向扩展总容量和吞吐,但一个 Key 仍然属于一个槽、由一个主节点负责。所以一个商品库存 Key 被 10 万个人抢,单纯增加 Cluster 节点,不会自动把这一个 Key 的写请求分散出去。另外,多个 Key 的事务或 Lua 原子操作通常要求同槽,Hash Tag 要按原子操作范围设计,不能为了方便把所有业务都塞进一个槽,反而制造新的热点。

第四步,如果真正的问题是热点 Key,就单独治理热点。 热点读可以用本地缓存、短 TTL 和请求合并;热点写则要看业务能不能拆,比如点赞可以按用户拆桶,再汇总计数。库存拆成多个桶时,每个桶只能发放自己分配到的额度,总额度不能超过真实库存,并且要考虑桶内卖完、其他桶还有余量时怎么调配。不能把同一份库存复制到多个节点,让每个节点都认为自己有 10 件,那样一定会超卖。
最后还要有保护措施。Redis 出现故障时,不能把所有请求毫无限制地回源到 MySQL;可以限流回源、返回允许范围内的旧数据,或降级非核心功能。扩容后也要监控迁槽、复制、命中率和延迟,避免迁移过程又把节点压满。
总结一下: 先排除慢命令和大 Key;读压力用本地缓存和允许延迟的副本读取分担;总体容量和写吞吐用 Cluster 分片扩展;单个热点则用请求合并或业务拆分处理。先找瓶颈,再选方案,主从、Sentinel 和 Cluster 解决的不是同一个问题。
# 如何设计一个可重入的分布式锁,用什么结构设计?
可重入锁的意思很简单:同一个持有者已经拿到了锁,再次进来还可以拿,不会把自己卡住;但每拿一次,就要对应释放一次。 比如外层方法拿了订单锁,里面又调用一个同样要拿订单锁的方法,这就是典型的重入场景。
如果用 Redis 来设计,我会用 Hash 保存持有者和重入次数,再用 Key 自身的 TTL 控制租约。这里有三个关键点:持有者是谁、重入了几次,以及什么时候自动失效。
第一步,设计数据结构和持有者标识。 比如资源是订单 10086,可以这样表示:
Key: lock:order:10086
Type: Hash
Fields:
owner = 应用实例唯一ID + 线程ID + 本次持锁唯一标识
count = 3
TTL: 30000 毫秒
不能只用线程 ID 或进程 ID,不同机器可能有相同的编号,进程重启后也可能重复。本次持锁标识用来区分同一线程先后两次独立的持锁过程;内层重入时必须复用外层的标识。这里的过期时间要通过 Redis 的 PEXPIRE 设置,只往 Hash 里放一个 expires_at 字段,Redis 不会自动把锁删掉。

第二步,加锁必须是一个原子操作。 我会用 Lua 把判断和修改放在一起:
- Key 不存在,就设置当前
owner、count = 1,同时设置 TTL,表示第一次加锁成功。 - Key 存在,且
owner与当前持有者相同,就把count加 1,并按租约策略续期,表示重入成功。 - Key 存在,但属于其他持有者,就返回加锁失败,或者返回剩余 TTL 给客户端作为等待参考。
不能先查 owner,再单独增加 count,因为这两步之间锁可能已经到期,被别人重新拿走了。等待锁也要有超时时间和退避,不能不停地死循环请求 Redis。需要更及时的唤醒时可以加订阅通知,但通知只负责提醒,醒来后仍然要重新竞争锁。
第三步,解锁也必须用 Lua 校验持有者。 只有 owner 完全匹配,才允许把 count 减 1。减完仍大于 0,说明外层还在使用锁,就继续保留;减到 0,才删除 Key 并通知等待者。Owner 不匹配时要明确返回未持有锁,不能直接 DEL。业务代码要在 finally 中释放,而且只释放确实拿到的那次锁。

第四步,处理业务执行超过 TTL 的情况。 可以用看门狗定期检查,只有 owner 仍匹配才续期,比如 30 秒租约每 10 秒尝试续一次。业务结束后停止续期,进程崩溃后锁就会自然过期。如果配置的是固定最大持锁时间,则要按这个语义执行,不能一边说最多持有 30 秒,一边无限自动续期。
这里有个特别关键的边界:锁到期,不代表旧线程已经停止运行。 假设线程 A 长时间 GC,锁过期后线程 B 拿到锁,随后 A 恢复,两个线程仍然可能同时修改业务数据。看门狗也不能完全消除这个问题。
对于不能接受旧持有者继续写入的场景,我会让资源端校验单调递增的 fencing token(栅栏令牌),拒绝已经落后的持有者写入。这个令牌要由能保证单调性的协调机制生成,并由实际存储端执行校验,不是把一个随机 owner 传过去就行。如果操作的是数据库记录,也可以结合版本号、状态条件更新和唯一约束来防止过期持有者覆盖新状态。

另外,普通 Redis 主从复制是异步的,主节点刚授予锁、还没复制就故障,新主节点可能再次授予同一把锁。所以对于强互斥要求很高的场景,我会考虑基于 ZooKeeper、etcd 这类协调组件的方案,同时保留资源端校验。在项目里也会优先用成熟客户端,不把几段 Lua 当成已经解决所有故障情况的完整锁框架。
总结一下: 可重入锁用 Hash 的 owner + count记录身份和次数,用 TTL防止崩溃后一直占锁,用 Lua保证加锁、重入、解锁、续期的原子性;再根据业务要求,通过资源端状态校验或栅栏令牌处理锁过期后的旧线程。锁机制和数据正确性要一起考虑。
# 你有看过一些负载均衡的一些方案吗?
有了解。如果面试官问负载均衡,我会从两个角度回答:流量在哪一层分配,以及按什么算法分配。 因为 Nginx、DNS、RPC 客户端都能分配流量,但它们看到的信息和能解决的问题不一样。
先说部署位置。 用户访问网站时,可以先通过 DNS 或全局流量调度选择地域,再经过四层或七层负载均衡把请求分给具体实例;服务之间的调用,也可以在 RPC 客户端拿到服务列表后自己选择目标节点。
- 四层负载均衡:主要根据 IP、端口和连接信息转发,比如 LVS,适合处理大量 TCP/UDP 流量,不需要理解具体的 HTTP 路径。
- 七层负载均衡:可以理解 HTTP 请求,按域名、路径、请求头等规则路由,比如 Nginx、HAProxy。它能把
/orders和/users分到不同服务,也方便做 TLS 终止、鉴权和限流,但会增加协议处理开销。 - 硬件或云负载均衡:比如 F5 或云厂商提供的负载均衡服务,可以承担统一入口。硬件和软件方案并不是简单的「哪个一定更快」,要看性能指标、功能、成本和运维条件。
- DNS 负载均衡:返回不同地址,把用户分配到不同入口,实现简单,但受 DNS 缓存和 TTL 影响,故障切换及流量比例控制不够精细。可以结合健康检查调整解析结果,但不能保证所有客户端立即停止访问故障地址。
- CDN:把可缓存内容放到离用户近的边缘节点,减少回源和延迟;动态业务仍然要设计源站的负载均衡和容量保护。

再说常见算法,我会按场景选择。
- 轮询、加权轮询:请求依次分配,机器能力不一样就配不同权重。实现简单,但如果请求耗时差异很大,请求数平均不代表实际负载平均。
- 随机、加权随机:大量请求下能获得较均匀的分布,客户端比较容易实现,也不需要共享轮询计数。
- 最少连接或最少在途请求:优先选择当前负担较轻的节点,适合处理时间差异较大的请求。不过长连接、HTTP/2 多路复用等场景里,连接数不一定能代表真实业务压力。
- 一致性哈希:同一个用户或业务 Key 尽量落到同一节点,适合需要缓存亲和性的场景。增减节点时只迁移部分映射,但要通过虚拟节点改善分布,也要防单个热门用户或 Key 压垮一个节点。
最后是可用性,算法之外还要做健康检查。 节点不可用要及时摘除,恢复后逐步放量,避免刚启动、缓存还没热就被打满;入口本身也要高可用,不能只有一台负载均衡机器。扩缩容时还要有优雅下线,让已有请求结束后再退出。
还有两个容易忽略的细节。第一个是会话,登录状态尽量放在共享存储里,避免所有请求都必须粘在同一台机器上。第二个是重试,如果某个节点执行了下单但响应丢了,负载均衡转发到另一台重试就可能重复下单,所以自动重试要受幂等性和总超时预算约束。
总结一下: 我会先用业务需求确定四层、七层和客户端负载均衡的分工,再按机器能力、请求耗时和缓存亲和性选算法,最后补上健康检查、入口高可用、优雅上下线和重试约束。把请求分出去只是第一步,避免把流量送到已经扛不住的节点同样重要。
# 10w顾客抢购一个只有10个库存的商品如何设计?
这个问题其实就是典型的「秒杀」场景。核心目标其实就两个:
- 第一,系统别被 10 万并发直接打崩;
- 第二,库存绝对不能超卖,用户体验也别太差。
我的整体思路是分三层:先在入口把流量「削平」,再在中间用一个足够快的组件做原子扣减,最后把下单这种重活异步化落库。

首先入口层我一定会做限流和隔离。像 Nginx / 网关层做全局限流、按 IP/用户限流,防刷和机器人校验也要上,比如验证码、滑块、设备指纹之类的。然后接口本身要做到极简,只干一件事:拿资格。因为如果每个请求都直接打到数据库去查库存,10 万并发数据库必死。
然后真正防超卖的关键我会放在 Redis 或者类似的原子计数里做。最常见的做法是把库存预热到 Redis,用 Lua 脚本做「检查库存>0并扣减」的原子操作,同时做一人一单的校验,以「活动 ID + 商品 ID + 用户 ID」记录资格,在同一个 Lua 脚本里完成判断和扣减。不能先独立 SETNX 再去扣库存,否则中间失败可能出现用户没拿到商品,却被标成已经抢过。这样正常并发下最多发出 10 个资格;最终仍由数据库条件扣减兜底,不能只把 Redis 原子性等同于跨系统的可靠性。
拿到资格之后我不会立刻同步走创建订单、扣数据库库存这种重流程,而是把它写进消息队列,比如 Kafka/RocketMQ。这样前端能很快收到「你已抢到资格,订单处理中」的结果,系统后面慢慢消费消息去真正创建订单、落库、扣真实库存。
数据库层我会用库存条件扣减兜底:UPDATE ... SET stock = stock - 1 WHERE id = ? AND stock > 0,只有影响行数为 1 才继续创建订单,并放在同一个本地事务里。这个语句是条件更新;如果说使用版本号乐观锁,还要加上 version = ? 条件并更新版本。即使 Redis 故障后多发了资格,数据库也不能生成超过真实库存数量的有效订单。
再往后,我会补两个关键的工程细节。
- 一个是「订单有效期」,因为抢到资格的人不一定付款,所以要给订单一个超时时间,比如 15 分钟不支付就自动取消,成功取消后再通过幂等释放事件归还库存,避免和支付回调并发、重复消息造成多次回补,这块也通常靠延迟消息和兜底扫描来做。
- 另一个是「重复请求和幂等」,因为用户会狂点、网络会重试,所以资格发放和下单消费都要有幂等键,比如以「活动 ID + 用户 ID + skuId」作为业务唯一约束,并给同一次请求固定的请求 ID,保证重试和重复消费不会重复创建订单。
这里还要补上「资格拿到了,消息却没发出去」的异常路径。预占资格要带有可恢复的待下单事件,发送失败重试,结果未知先查状态,确认失败后才能回补,不能看到 MQ 超时就立即把库存加回来。
如果面试官追问:只有 10 件库存,真的必须上 MQ 吗?我的回答是,只有一个商品、下单逻辑又短时,同步处理这 10 笔订单也可以。真正要挡住的是前面 10 万个抢资格的请求,MQ 主要用于多商品活动或较长下单链路的解耦,不能为了凑组件强行引入。售罄后还可以短暂缓存售罄标记,减少重复请求;若取消订单重新释放库存,则要及时撤掉这个标记。
总结一下就是:入口限流,Redis 原子预占资格,可靠事件和 MQ 异步下单,数据库条件扣减与唯一约束兜底,再加上订单状态查询、超时释放和幂等补偿。10 个资格进入重流程,剩余请求尽早返回,才能把 10 万抢 10 个的场景稳住。
# 微服务架构有了解吗?你认为什么是微服务架构?
简单说微服务就是把原来一个大的单体应用拆分成多个小的独立服务,每个服务负责一块具体的业务功能,可以独立开发、独立部署、独立扩展。

微服务最大的好处就是灵活。比如电商系统,我们可以按用户、订单、商品、支付这些业务领域划分服务。如果订单处理的应用层成为瓶颈,就可以只扩订单服务,不必整个系统一起扩容;但数据库或库存服务已经满了,单独加订单实例也不能解决问题。不同团队能围绕自己的业务独立开发和部署,不过前提是接口契约稳定、服务边界清晰,否则一次发布还是要拉着所有服务一起改。

但微服务也不是银弹,它带来的复杂度其实挺高的。首先服务之间要互相调用,这就需要服务注册发现,我们一般用Nacos或者Eureka这种注册中心,让服务启动时把自己注册上去,调用方从注册中心拉取服务列表。然后调用方式上可以用HTTP的RESTful或者RPC框架像Dubbo、gRPC,这些都要考虑负载均衡、超时重试、熔断降级这些容错机制。

链路追踪也很重要,一个请求可能要经过五六个服务才完成,出了问题不好排查,所以我们会用Skywalking或者Zipkin把整个调用链路串起来,每个请求都带一个TraceID,方便追溯。配置管理也是个问题,微服务多了之后配置文件一大堆,我们用配置中心统一管理,可以集中下发配置。支持动态刷新的配置可以不重启服务,但数据库连接、线程池等配置是否能安全热更新,要看具体实现,不能认为所有配置改完都会立刻生效。
网关是微服务的入口,像Spring Cloud Gateway或者Nginx,负责路由转发、鉴权限流这些统一处理的逻辑。服务间通信如果都是同步调用,一个服务慢了会拖累整条链路,所以有些场景我们会用消息队列做异步解耦,比如订单已确认后,发消息触发通知、积分等后续操作。库存这种决定订单能否成立的核心条件,通常要先预占,或者让订单处于「处理中」等待库存确认,不能还没扣到库存就向用户宣布下单成功。
数据管理也比较头疼,微服务一般提倡每个服务有自己的数据库,不能直接访问别人的表,这样能做到服务自治。但这也带来分布式事务的问题,一个业务操作涉及多个服务的数据修改,要按业务拆开考虑。比如订单创建和库存预占可以用 TCC 的 Try/Confirm/Cancel 协调,TCC 需要业务实现资源预留、确认和取消,并处理幂等、空回滚和悬挂;长流程可以用 Saga,失败后逐步执行补偿,但补偿并不等于数据库自动回滚,也可能需要重试和人工处理。通知、积分这类允许延迟的操作,则适合本地事务消息表加 MQ 的最终一致性。Seata 等框架可以辅助实现某些模式,但不会替我们定义业务边界和补偿规则。
另外,我不会按数据库表来机械拆服务,更不会每个 CRUD 都拆成一次远程调用。服务要围绕相对完整的业务能力划分,高内聚、低耦合,并尽量由明确的团队负责。微服务可以共用物理数据库实例,但数据所有权要清楚,其他服务通过接口访问,避免跨服务直接改表,否则部署看起来独立,数据依赖却还是绑在一起。
稳定性上,我会给服务调用设置总超时预算和有界线程池,对故障依赖做熔断、对资源竞争做隔离,再通过日志、指标和 TraceID 联合定位问题。不要在调用链每一层都默认重试三次,否则一个下游故障会被层层放大。
我觉得微服务适合业务复杂、团队规模大、需要快速迭代的场景。如果就是个小项目,团队就三五个人,其实单体应用更合适,不要为了微服务而微服务。架构选型一定要结合实际情况,微服务能解决很多问题,但也会引入新的复杂度,这个要权衡好。
# 对于你的项目(单体),怎么拆分成微服务架构呢?
拆分微服务我觉得不能一上来就全拆,得循序渐进。首先我会梳理现有系统的业务模块和依赖关系,画个模块依赖图,看哪些模块相对独立、哪些耦合比较重。
然后按照业务领域来划分服务边界,遵循领域驱动设计的思想。比如电商系统,用户、商品、订单、支付、库存这些是不同的业务领域,职责比较清晰。我会优先拆那些业务独立、变更频繁或者需要单独扩展的模块,比如通知、商品查询这类依赖相对少的模块,可以先试点;支付模块虽然有独立部署的价值,但一致性和故障成本也高,不一定适合作为第一次拆分的练手对象。

拆的时候我会用绞杀者模式,新功能用微服务开发,老功能逐步迁移。先拆一个相对简单的模块试水,把注册中心、配置中心、网关、监控这些基础设施搭起来,等第一个服务跑稳了再陆续拆其他模块,这样风险可控。
数据库拆分是个难点。我会先做逻辑拆分,明确不同服务负责哪些 schema 或表,物理上可以暂时仍在一个库,但不再允许跨服务直接读写对方的表。等服务边界稳定了再做物理拆分,把数据迁到各自的数据库。原来用事务能解决的一致性问题,拆开后可能要用分布式事务或者最终一致性方案,有些强一致的场景我会考虑暂时不拆。

真正迁数据时,我会明确任一阶段哪个系统是写入的唯一入口。常见做法是先做全量复制,再用 CDC 或可靠事件追上增量,核对数量、关键字段和版本,确认新库追平后再切换读写路由。如果一边读新服务,一边还允许老系统绕过它直接改原表,数据很快就会不一致。
我不会靠应用层「写旧库,再写新库」来保证同步,因为其中一次失败就会分叉。确实需要两边同步时,要有可靠日志、幂等和对账机制。灰度可以按租户或用户稳定分流,但同一笔订单要有明确归属,不能随意在新旧写路径之间跳来跳去。回滚也要考虑新系统已经产生的数据怎么带回去,不能只把网关路由切回旧系统就宣布恢复。

服务间调用方面,原来直接方法调用,拆开后要走网络,我会用Dubbo或Feign做远程调用,加上超时控制、熔断降级。如果调用链路长,还可以用消息队列做异步解耦。
公共代码我会只抽稳定的技术能力,比如日志、鉴权客户端和基础工具。业务实体和流程不要全部塞进一个共享 SDK,否则每次改业务都要所有服务升级,反而失去了独立发布的意义。公共能力是否做成服务,也要看数据所有权和调用成本,不是所有复用代码都值得变成远程调用。监控和日志也要升级,用ELK做日志收集,加上链路追踪方便排查问题。
整个过程我会保持小步快跑,每拆一个服务都验证接口兼容性、数据对账、故障恢复和端到端流程,再做灰度发布。上线后用响应时间、错误率、数据差异和独立发布效果来判断是否真的改善,并准备包含数据处理的回滚预案。我觉得拆分核心是业务驱动,真正能解决单体应用的扩展性和开发效率问题,而不是为了拆而拆。
# 对外提供一个api服务,客户说请求接口超时了,怎么排查?
我第一步会先问清楚基本情况:是偶发还是持续超时?哪个接口、哪个时间段?能不能提供请求 ID、客户端超时配置和完整错误信息?是所有客户还是个别客户?这里先要弄清楚,客户说的「超时」到底是连接超时、等响应超时,还是已经收到网关的 504。这些信息能帮我判断问题发生在哪一段,不能上来就认定是服务器执行慢。

然后我会立刻看监控大盘,重点看接口的 P95/P99 响应时间、QPS、错误率和超时比例,而不是只看平均耗时。平均 100 毫秒,也可能有一小部分请求等了 10 秒。同时看 CPU、内存、网络、线程池队列和连接池等待有没有异常。确认是整体都慢、只有某个接口慢,还是只有某个客户所在地域慢。
接下来我会用请求 ID 串起客户、入口网关、应用和下游日志。如果请求压根没到网关,就查 DNS、TCP/TLS 建连、代理和网络;如果到了网关但没进入应用,就看路由、连接等待和上游连接限制;如果进了应用,就通过链路追踪拆分耗时,看是等线程、等数据库连接、执行 SQL,还是调用下游服务。
这里要区分「真正执行慢」和「排队慢」。比如 SQL 执行只花 20 毫秒,但获取数据库连接等了 3 秒,慢查询日志可能看不出问题;下游业务只花 100 毫秒,但客户端连接池一直满,也会表现成接口超时。如果用了 Skywalking 这类工具,也要把连接池等待、线程池排队这些阶段纳入指标,不能只看业务方法的 Span。

数据库这块我会重点关注,结合慢查询、执行计划和锁等待,看有没有 SQL 索引不合适、事务持锁过久,以及连接池等待时间和活跃连接数异常。如果用了Redis缓存,还要看缓存命中率有没有下降,缓存失效可能导致大量请求打到数据库。
下游依赖也是重灾区,我会确认调用的其他服务有没有故障或者变更。网络层面也要排查,看有没有丢包、DNS解析慢、或者跨地域网络抖动这些问题。

如果最近有过发布,我会特别注意是不是新代码引入的问题,比如加了耗时操作或者配置改错了。还要看 JVM 有没有长时间 GC 暂停、线程阻塞或死锁,以及容器是否因 CPU 配额被节流、内存是否接近上限。CPU 不高也可能只是线程都在等锁,所以必要时结合线程栈和性能采样来判断。
还有一个必须核对的地方是整条链路的超时预算。比如客户只等 2 秒,网关却允许应用执行 10 秒,那客户放弃以后,后端可能还在持续占用资源。应用调用下游的等待时间、重试次数和排队时间都要计入剩余预算,尽量让内部调用先返回明确结果,给外层留出网络和响应开销。不能只是把所有超时参数都调大。
如果超时的是下单、扣款这类写接口,我还会先查业务状态。没收到响应不代表执行失败,客户重试要沿用原幂等键,必要时通过结果查询接口确认,避免修好了性能却多扣了一次钱。
找到原因后我会针对性处理:近期发布引入的问题先回滚,依赖故障就熔断降级,资源耗尽就限流或扩容,数据库问题再优化 SQL、索引和事务。恢复后,用同样的请求条件验证客户端实际成功率和 P99 延迟,并观察错误率、资源水位和队列是否回落。最后补告警、容量预案和复盘,不能看到应用日志不报错就认为客户问题已经解决。
我觉得排查超时的核心是从全局到局部,先看监控定范围,再查日志找细节,同时要关注数据库、下游依赖、网络、代码变更这几个高频问题点,这样能更快定位问题。
# 如何设计一个幂等接口,避免重复下单或重复扣款?
幂等其实就一句话:同一笔业务请求,不管执行一次还是重试多次,最终只产生一次业务效果。 用户连续点击、客户端超时重试、MQ 重复投递,都会把这个问题带出来。所以不能只靠前端按钮置灰,后端一定要兜底。
第一步,先定义「什么算同一笔请求」。 比如创建订单,客户端第一次操作时生成一个请求 ID,后续重试沿用它;服务端把它和用户、接口等作用域一起作为幂等键。如果是支付回调,就使用支付渠道和渠道交易号来去重。不能只用用户 ID,因为同一个用户可以下很多笔订单;也不能每次重试都重新生成请求 ID,那样每次都会被当成新业务。
我还会保存请求参数摘要。同一个幂等键再次传来,参数却变了,比如第一次扣 100 元,第二次扣 200 元,就要拒绝,不能直接当成同一笔交易返回成功。
第二步,让「防重复」和「业务执行」在同一个可靠边界里。 如果订单就在 MySQL,我会给幂等键建唯一索引,在本地事务里插入请求记录、创建订单、保存处理结果。两个相同请求同时到达,只有一个能成功占用这个键;另一个等待事务结果后,查已有记录并返回对应订单。
幂等记录可以有「处理中、成功、明确失败」等状态。已经成功就返回保存的结果,正在处理就告诉客户端继续查询,结果未知就核查业务状态。不能只在成功以后才记一个去重标记,因为两个请求可能在标记写入前都执行了扣款。

第三步,状态更新要带条件。 比如支付成功只允许把订单从「待支付」改为「已支付」,并检查影响行数。只有这次转换确实成功,才记录积分、通知等后续事件;重复回调发现已经支付,就幂等返回。状态转换和事件记录放在同一个事务里,可以避免状态改了两次、后续通知也发两遍。
第四步,跨系统调用要继续传递幂等键。 本地接口做了去重,不代表第三方支付自动去重。我会给下游传固定的业务单号,使用它提供的幂等能力;调用超时后先按单号查询结果,确认未执行才决定是否重试。如果下游既不支持幂等也不能查结果,就不能承诺靠本地一个 Redis 锁解决重复扣款,需要协调业务协议或增加人工核查路径。

Redis 的 SET NX 可以拦掉短时间内的大量重复请求,但它更适合入口优化。Key 过期、Redis 故障、业务执行时间超过锁租约,都可能让重复请求再次进入,最终仍要靠数据库唯一约束、状态机和下游幂等保证业务效果。
总结一下: 固定的业务幂等键识别同一笔请求,唯一索引和事务控制并发,状态条件更新避免重复推进,保存结果让重试返回原结果;跨系统操作则继续使用相同业务单号,并补上结果查询和对账。去重记录保存多久,也要覆盖客户端重试、消息重放和业务对账的周期。
# 如何保证消息队列中的消息不丢失、不重复处理?
这个问题我会沿着消息走过的三个位置回答:生产者发出去、Broker 存下来、消费者处理完。 每一段都可能失败,不能只说「开了持久化」就认为整条链路可靠了。
第一段,生产者要知道消息到底有没有发成功。 发完以后要等 Broker 的确认,根据所用 MQ 配置相应的确认机制。明确失败就重试,确认超时则表示结果未知:Broker 可能已经收到了,只是确认没回来。所以重试时要沿用原来的事件 ID,不能每次重试都生成一条全新的业务事件。
这里还有个更常见的问题:数据库事务已经提交,应用却在发 MQ 前崩溃了怎么办?我会用本地事务消息表,也叫 Outbox。比如创建订单时,在同一个数据库事务里写订单和待发送事件;后台扫描待发送事件或通过 CDC 投递 MQ,得到确认后再标记发送完成。如果发送成功、标记前进程崩溃,事件会再次发送,所以消费者依然必须幂等。事务消息表解决的是「业务提交了,事件却永远没发出去」的断点。

第二段,Broker 要根据可靠性要求配置存储和复制。 我会检查消息持久化、复制副本、确认条件、保留时间和故障切换策略,并关注磁盘水位与副本健康。不同 MQ 的确认语义不一样,要弄清楚确认到底代表收到、写盘,还是达到要求的副本条件,不能只看客户端方法没有抛异常。
第三段,消费者必须处理成功以后再确认。 消费者拿到消息后,在本地事务里插入事件去重记录并修改业务数据,提交成功后才 ACK 或提交消费位点。事务失败就不确认,按策略重试。如果事务成功,但消费者在确认前崩溃,消息会再次投递;这时通过事件 ID 的唯一约束发现已经处理过,直接确认即可,不能重新扣库存。

如果消息处理还要发短信或调用另一个系统,本地事务不能直接包住这些外部效果。我会继续使用事务消息表发送后续事件,或者向下游传幂等键,让每个边界都有自己的可靠处理规则。
重复能解决了,乱序怎么办? 对顺序有要求的事件,比如同一个订单的创建、支付和取消,要按订单 ID 路由到同一有序分区或队列,并保证对应的消费处理顺序;有些场景也可以用业务版本号和状态机拒绝旧事件。不同订单通常没必要全局串行,否则一个慢订单就会阻塞所有订单。
最后,重试不能无限原地循环。临时故障用带退避的有限重试;超过次数就进入死信或异常处理队列,告警后核查,再受控重放。还要监控消息积压、最老消息等待时间、重试率和消息表滞留量,避免消息没丢,但排了几个小时也没人处理。
总结一下: 生产端用确认和事务消息表保证事件可恢复,Broker 按要求做好持久化与复制,消费端用事务、幂等和处理后确认保证效果。工程上通常按「至少一次投递、业务效果幂等」设计,不轻易承诺一个配置就能让所有外部业务恰好执行一次。
# 如何保证 Redis 缓存和 MySQL 的数据一致性?
我会先确认业务允许多大的延迟。商品介绍晚一两秒更新,和支付余额读错,是两种完全不同的要求。缓存是用来减少重复读取的,不能因为加了缓存,就把所有业务都当成允许最终一致。
对于常见的读多写少业务,我会采用 Cache Aside,也就是旁路缓存:读取时先查 Redis,未命中再查 MySQL 并回填;修改时先提交数据库事务,再删除对应缓存,让下次读取重新加载。
为什么是删缓存,而不是每次都更新缓存? 假设两个写请求先后修改数据库,但更新缓存时顺序反了,旧值可能覆盖新值。删除缓存可以减少这种写入顺序问题,而且有些缓存是多张表组合出来的,直接更新并不简单。不过这只是比较实用的默认方案,不代表它没有并发窗口。
第一个窗口,是数据库成功了,缓存删除失败。 我会在数据库事务里记录失效事件,再通过后台任务或 MQ 重试删除,也可以订阅数据库变更来驱动失效。同时设置合理 TTL,避免一次删除失败让旧值长期存在。删除操作天然适合重试,但事件积压、失效失败仍要监控。
第二个窗口,是旧值晚回填。 比如线程 A 读缓存未命中,从数据库读到了旧值;线程 B 更新数据库并删除缓存;随后 A 才把手里的旧值写回 Redis。这时虽然 B 已经删过一次,缓存还是变旧了。

如果业务允许短暂旧值,可以用 TTL、请求合并和后续失效补偿缩短影响时间。「延迟双删」也能降低某些竞争窗口的概率,但睡多久没有绝对答案,慢查询、网络抖动都可能超过这个时间,所以不能说双删就保证强一致。读取从库时还要考虑复制延迟,否则缓存刚删掉,又可能从从库回填一个旧版本。
如果一致性要求更高,我会收紧读写路径。 比如关键的订单状态和余额直接读主库;有版本控制的缓存,在需要时校验当前业务版本。也可以让同一业务 Key 的更新、失效和重建通过受控的串行机制执行,但读写双方都要遵守协议,并处理锁租约和故障,不能仅在缓存未命中时加一把锁,就认为所有并发写都被解决了。
另外,缓存穿透、击穿、雪崩要配套处理:不存在的数据用短 TTL 空值缓存或布隆过滤器挡住;热点失效用请求合并减少同时回源;大量缓存到期用 TTL 随机偏移分散,再配上限流回源和降级。缓存不可用时,也不能把所有流量直接倒给 MySQL。
总结一下: 普通业务采用先提交数据库、再删缓存,用可靠失效事件、重试和 TTL 收敛差异;更高一致性要求则通过主库读取或严格的版本、读写协议保证。面试里要讲清楚能接受什么窗口,以及窗口出现后怎么恢复,不能只背一句「延迟双删」。
# 如何设计一个限流系统,常见限流算法怎么选?
限流的目标不是把请求全部挡住,而是让进入系统的流量控制在它能处理的范围内,优先保护核心业务。 我会先确定限流维度和容量:是整个服务、某个接口、某个用户、某个租户,还是某个热点商品?一个全局阈值通常不够,因为一个大客户或热点接口就可能把其他请求挤出去。
第一步,选择算法。 常见方案我会这样比较:
- 固定窗口:比如每秒最多 100 个请求,计数达到阈值就拒绝。实现简单,但上一秒末尾 100 个、下一秒开头又来 100 个,短时间内仍可能冲进 200 个请求。
- 滑动窗口:统计最近一段时间的请求,可以用请求时间记录,也可以分成多个小时间桶。比固定窗口更平滑,但精确记录每次请求会消耗更多内存,小桶计数则是近似统计。
- 令牌桶:按固定速率放入令牌,桶有容量上限,请求拿到令牌才放行。平时积攒的令牌可以吸收短时突发,适合希望允许小洪峰、限制长期平均速率的场景。
- 漏桶:把进入的请求按相对固定的速率放出,适合下游需要平滑流量的场景。如果用排队实现,就要限制队列长度和等待时间,不能流量过大以后让用户无限等待。

我一般会用令牌桶做接口速率限制,再根据资源情况加并发数限制。因为每秒 100 个请求如果各执行 10 秒,系统可能有大约 1000 个请求同时占着资源;QPS 没超标,不代表线程池和连接池就安全。
第二步,区分单机保护和全局配额。 每个实例的本地限流响应快、不依赖网络,适合保护自己的线程和连接。但如果 10 个实例各限 100 QPS,总体上限就是大约 1000 QPS,扩容以后总体配额还会变化。业务要求租户在整个集群只能每秒调用 100 次时,就需要共享计数或协调配额。
可以用 Redis + Lua 原子地判断和扣减配额,小规模下比较直接;但所有请求都访问一个全局 Key,也可能制造热点。规模更大时,可以把配额分配给网关实例,本地快速扣减,定期领取新额度。这里要说清楚是严格配额还是允许少量偏差,已经分出去的额度不能因为实例暂时失联就无条件重复发放。

第三步,定义超限和故障时的行为。 超限返回明确的 HTTP 429 或业务错误,客户端按退避策略重试,必要时提供 Retry-After。不能让所有客户端马上重试,否则限流后又产生新一轮流量。限流存储故障时,普通查询可以回退到保守的本地限流,资金和严格配额业务则可能需要拒绝,不能统一选择全部放行。
最后,阈值要根据压测和线上水位来定,结合核心业务优先级、超时、熔断和资源隔离调整。限流是控制进入多少,熔断是暂时停止调用故障依赖,两者可以配合,但不是同一个机制。
总结一下: 先定维度和容量,再选算法;令牌桶允许有限突发,漏桶平滑输出,滑动窗口控制近期速率;同时做好单机并发保护、全局配额协调和超限退避,才是一套能用的限流设计。
# 如何设计分库分表,分片键怎么选,后续怎么扩容?
我会先确认为什么要拆:是存储容量太大、单库写吞吐不够,还是查询本身没有优化好?如果只是少了索引、深分页或事务太长,分库分表可能只是把同一个问题复制到更多数据库。先把单库瓶颈确认清楚,再决定怎么拆。
第一步,围绕主要访问路径选择分片键。 比如订单系统最常见的是用户查看自己的订单,就可以按 user_id 分片,让一个用户的订单尽量在同一分片里。查询知道分片键,就能直接找到对应库表;不知道分片键,只拿订单号来查,就需要让订单号携带路由信息,或者维护订单号到分片的映射,不能每次都扫描所有库。
分片键还要尽量稳定、分布均匀,并让经常一起做事务的数据落在一起。租户 ID 虽然很符合业务边界,但一个特别大的租户可能独占热点;按用户分片也可能遇到大用户,需要结合实际分布设计隔离或进一步拆分。
第二步,选择分片方式。 哈希分片分布通常更均匀,但范围查询要跨分片;按时间、ID 范围分片方便归档和范围查询,但新数据可能集中写进最新的分片。两者没有固定的最优答案,核心是匹配查询模式和热点分布。
我会尽量用「业务 Key -> 逻辑分片 -> 物理库」的路由层,把逻辑分片数和物理机器数分开。简单地 user_id % 数据库数量,数据库从 4 个变 5 个时,大量数据都会换位置;固定逻辑桶以后,可以通过迁移部分桶扩容,而不是让所有数据都重新取模。

第三步,提前处理拆分后的限制。 数据库自增 ID 在不同分片间可能重复,所以要设计全局 ID;全局唯一约束也不再由单个分片索引自动保证,要让同一唯一业务键落在同一约束范围,或使用可靠的统一约束服务。跨分片事务、Join、排序分页和聚合查询都会变复杂,不能假装只是换了表名。后台统计通常可以通过 CDC 或 MQ 汇总到专门的查询、分析存储里。
第四步,扩容要有完整的数据迁移流程。 我会先创建目标分片、做全量迁移,再可靠地同步增量,校验数量、关键字段和数据版本。切换前要明确写入的归属和路由版本,必要时对迁移桶短暂暂停写入,确认目标追平后再切换,避免两个库同时接收同一份数据的写入。
迁移过程中不能先改路由再慢慢搬历史数据,否则用户会查不到自己的订单。旧数据也不能切换完成就立即删除,要留出核查时间;回滚时要考虑目标库已经接收的新写入怎么同步回旧库,不能只把路由拨回去。迁移速率也要受控,避免复制流量把线上数据库的 IO 和复制链路压满。
总结一下: 分库分表的关键不只是「拆多少张表」,而是分片键能不能覆盖主要查询、热点会不会倾斜、事务边界怎么变化,以及扩容能不能安全迁移。我会把路由、全局 ID、跨分片查询和数据对账一起设计,最后通过真实数据分布的压测验证效果。
最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。

← 分布式面试题 Linux命令面试题 →
