Post

后端系统设计

核心四板斧:限流 → 缓存扣减 → 异步落库 → 最终一致。 1. 前端/网关限流:按钮置灰防重复、Nginx/网关限流、答题验证码削峰。 2. 库存预热到 Redis:下单先在 Redis 用原子操作扣减,扛住高并发,DB 不直接顶流量。

后端与工程 阅读 0 点赞 0 评论 0

导语

在高并发、高可用的后端系统设计中,如何保障秒杀场景的稳定性、解决缓存与数据库的一致性问题、实现分布式环境下的资源安全控制,是衡量工程师技术深度的关键。本文将围绕秒杀下单、缓存一致性、分布式锁等核心场景,拆解每个环节的技术原理与落地策略,帮助你构建健壮的后端系统思维。

1. 秒杀 / 高并发下单:如何扛住百万级流量?

秒杀场景的核心挑战是瞬时流量峰值库存有限性的矛盾,需通过多层防护实现“削峰填谷”与“安全兜底”。我们将从“限流→缓存扣减→异步落库→最终一致”四个关键步骤展开:

1.1 流量削峰:前端/网关限流

核心目标:在请求到达后端服务前,通过前端和网关层过滤无效请求,避免直接冲击核心系统。

  • 前端防护:秒杀按钮置灰(防用户重复点击)、验证码(如答题验证码),降低非真实用户的请求量。
  • 网关限流:Nginx 或 API 网关层通过令牌桶/漏桶算法限制单位时间内的请求数,例如配置 limit_req zone=one burst=5 nodelay 限制每秒 100 个请求。
  • 削峰填谷:通过 Redis 预扣减库存、消息队列异步处理,将突发流量转化为平缓的消费节奏。

1.2 库存预热:Redis 扛住高并发

核心思想:将库存数据从数据库迁移到 Redis,利用 Redis 的原子操作能力直接处理高并发请求,避免数据库成为流量瓶颈。

  • 库存预热:系统启动时,将商品库存数据批量加载到 Redis(如 SET product:1001:stock 1000),确保 Redis 中数据与数据库一致。
  • Redis 原子扣减:下单时优先在 Redis 执行原子操作扣减库存,例如 DECR product:1001:stock(原子递减),或通过 Lua 脚本实现“判断+扣减”的原子性(如 EVAL "local stock = redis.call('DECR', KEYS[1]) if stock < 0 then redis.call('INCR', KEYS[1]) return 0 else return stock end" 1 product:1001:stock)。
  • DB 隔离:Redis 仅作为流量缓冲层,数据库不直接处理高并发请求,仅在异步落库时更新真实库存。

1.3 防超卖:原子操作与兜底策略

核心问题:如何避免“库存扣减后仍显示有库存”或“重复下单”?

  • 原子扣减:通过 Redis 的原子操作(DECR 或 Lua 脚本)保证“判断库存是否充足”与“扣减库存”在同一原子操作中完成,避免并发下的竞态条件。例如,使用 Lua 脚本:
    lua -- 判断库存是否 >0 并扣减 local stock = redis.call('GET', KEYS[1]) if tonumber(stock) > 0 then return redis.call('DECR', KEYS[1]) end return 0
  • 三级兜底
    1. Redis 层:库存扣减至 0 后直接拒绝新请求(如 stock <= 0 时返回“已售罄”)。
    2. MQ 层:通过消息队列串行化消费下单请求,避免多线程并发操作。
    3. DB 层:使用 UPDATE stock=stock-1 WHERE id=? AND stock>0,通过影响行数判断是否扣减成功(若影响行数=0,说明库存已不足)。

1.4 异步落库:削峰填谷的关键

核心逻辑:将“抢到资格”的信号异步发送到消息队列,后端消费者缓慢处理订单生成,避免瞬时流量直接冲击数据库。

  • 流程:用户下单成功(Redis 扣减库存)→ 消息入队(如 MQ.send("order_topic", {orderId, userId, stockId}))→ 消费者从队列中拉取消息,执行 DB INSERT 生成订单。
  • 优势:通过 MQ 缓冲,峰值流量被“削平”,数据库按消费能力匀速处理请求,避免“秒杀后 DB 崩溃”。

常见追问:如何避免超卖?

三道防线
1. Redis 原子扣减:通过 Lua 脚本保证“判断+扣减”不被中断,是第一道防线。
2. MQ 串行化消费:确保同一商品的下单请求按顺序处理,避免并发冲突。
3. DB 乐观锁兜底:使用 UPDATE ... WHERE stock>0,若影响行数=0,说明库存已被其他请求抢占,直接回滚。

常见追问:不用 Redis 只用 MySQL 怎么保证安全?

依赖 DB 本身的原子性
- 行锁 + 条件更新UPDATE stock SET stock=stock-1 WHERE id=? AND stock>0,通过 stock>0 条件与行锁保证原子性(影响行数=0 即无库存)。
- 悲观锁SELECT stock FROM stock WHERE id=? FOR UPDATE 锁定库存行,防止其他事务修改。
- 代价:行锁会降低数据库并发性能,仅适用于低并发场景(如秒杀流量 < 1000 QPS)。

2. 缓存与数据库一致性:如何避免“脏数据”?

缓存与数据库的一致性是高并发系统的核心矛盾:若缓存更新不及时,会导致“读旧数据”;若更新过度,又会浪费性能。主流方案是 Cache Aside(旁路缓存)+ 延迟双删,我们分步骤拆解:

2.1 读操作:先读缓存,miss 再读 DB

流程
1. 客户端请求数据 → 先查 Redis 缓存。
2. 若缓存命中(HIT),直接返回数据。
3. 若缓存未命中(MISS),查 MySQL 数据库,将结果写入 Redis 后返回。

优势:缓存仅存储热点数据,减少数据库压力;读操作通过“缓存优先”降低延迟。

2.2 写操作:先更新 DB,再删除缓存

为什么不更新缓存?
- 更新缓存的弊端:① 若数据仅被少数人访问,更新缓存会浪费性能(如“冷门商品库存”);② 并发场景下,“更新缓存”的顺序难以保证(如 A 更新缓存后 B 覆盖,导致数据不一致)。
- 删除缓存的优势:下次读时自动从 DB 回填最新数据,是“惰性更新”,避免无效更新。

流程
1. 先更新 MySQL 数据库(确保数据真实写入)。
2. 立即删除 Redis 缓存(避免缓存中仍有旧数据)。

⚠️ 关键:若先删缓存再更新 DB,若更新 DB 期间有读请求,会从缓存中读旧数据(此时缓存已被删除,需重新读 DB 回填),但“先删后更”会导致短暂数据不一致。因此,必须先更 DB 后删缓存

2.3 延迟双删:解决缓存回填问题

场景:若先删缓存 → 更新 DB → 此时有新读请求命中缓存(缓存已被删,需读 DB 回填),但若此时 DB 更新未完成,会导致缓存中是“旧 DB 数据”。

延迟双删策略
1. 第一次删缓存:确保旧数据被清除。
2. 更新 DB:执行核心业务逻辑。
3. 延迟几百毫秒后第二次删缓存:清除“更新 DB 期间被新请求回填的缓存”。

延迟时间:建议 300~500ms,根据业务场景调整(如秒杀场景可缩短至 100ms)。

常见追问:为什么删缓存而不更新缓存?

从性能和一致性角度分析
- 更新缓存浪费资源:若数据仅被少数人访问,频繁更新缓存会增加 Redis 内存和 IO 开销。
- 并发场景难保证顺序:例如 A 写缓存=10,B 写缓存=20,若 A 先更新缓存,B 后更新,最终缓存是 20(正确);但若 B 先更新,A 后更新,缓存会被 A 覆盖,导致数据错误。而删除缓存,下次读时自动从 DB 取最新值,避免顺序问题。

3. 分布式锁:如何在多实例间安全抢资源?

在分布式系统中,多个服务实例可能同时操作同一资源(如库存、订单),需通过分布式锁保证“同一时间只有一个实例操作”。Redis 是最常用的分布式锁实现,核心是 原子操作 + 超时控制

3.1 Redis 分布式锁实现

核心命令SET key value NX EX 30
- NX:仅当 key 不存在时才设置(避免重复加锁)。
- EX 30:设置 30 秒过期时间(防死锁,若服务崩溃,锁自动释放)。
- value:存储唯一标识(如 UUID),避免误删其他服务的锁。

释放锁:必须通过 Lua 脚本保证“判断+删除”的原子性,避免“锁过期后被其他服务抢锁”:

-- 判断锁是否为当前服务的,若是则删除
if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
end
return 0

3.2 进阶问题:如何解决锁过期与死锁?

  • 看门狗(Redisson 实现):若服务持有锁但未完成操作,Redisson 会定期续期(如每 10 秒续 30 秒),确保锁不提前过期。
  • 主从切换锁丢失:单 Redis 实例可能因主从切换导致锁丢失,需用 RedLock 算法(向 5 个以上 Redis 节点加锁,多数节点成功才算加锁成功)。

4. 消息队列可靠性:如何保证“消息不丢、不重复”?

消息队列是异步通信的核心,但需解决“生产端丢消息”“Broker 丢失消息”“消费端重复消费”三大问题。

4.1 三端保障:生产端、Broker、消费端

1. 生产端:确保消息不丢
- 开启 confirm 机制:发送消息后,等待 Broker 确认(ACK),未确认则重发。
- 消息持久化:生产者将消息存入本地日志,防止 Broker 重启丢失。

2. Broker:消息持久化与高可用
- 持久化到磁盘:Kafka 或 RabbitMQ 开启消息持久化(如 Kafka 的 retention.ms 控制消息保留时间)。
- 多副本冗余:Kafka 配置多副本(ISR 机制),确保主节点故障后从节点可接管。

3. 消费端:确保消息不丢、不重复
- 手动 ACK:消费端处理完消息后,主动调用 ACK,若处理失败则消息重回队列(不丢)。
- 幂等性消费:通过唯一 ID(如订单号)去重,避免重复执行(见“幂等性”章节)。

常见追问:进程崩溃/K8s 重启后任务会不会丢?

不会,前提是:
- 消息持久化:消息未被消费时,Broker 持久化到磁盘。
- 手动 ACK:消费端未 ACK 时崩溃,重启后消费者重新拉取未消费的消息。
- 状态外化:任务进度需存储在 DB/Redis(如“已处理到第 100 条”),避免内存中状态丢失。

常见追问:断点续跑怎么实现?

核心思路:将长任务拆分为可记录的子步骤,每步完成后记录进度。
- 实现方式
1. 任务启动时,读取 Redis/DB 中的“已完成进度”(如 last_id=100)。
2. 从 last_id+1 开始消费消息,每处理一条记录 last_id
3. 若任务中断,重启后从 last_id 继续,避免重复消费。

5. 幂等性:如何避免“重复请求”导致的数据错误?

幂等性指“同一请求多次执行,结果与单次执行一致”。在支付、订单创建等场景中,重复请求可能导致“重复扣钱”“重复发货”,需通过以下策略实现:

5.1 唯一约束:DB 唯一索引

  • 场景:订单号、支付流水号等需全局唯一的 ID。
  • 实现:在 DB 表中对订单号加唯一索引,重复插入时 DB 直接报错(如 MySQL 1062 错误),通过捕获异常处理重复请求。

5.2 Token 机制:防前端重复提交

  • 流程
    1. 前端请求前,后端生成唯一 Token(如 UUID),存入 Redis(SET token:xxx 1 EX 60)。
    2. 前端携带 Token 发起请求,后端验证 Token 是否存在且未过期,验证通过后删除 Token。
    3. 重复请求时,Token 已被删除,直接返回“重复请求”。

5.3 状态机:按业务规则流转

  • 场景:订单状态(待支付→已支付→已发货)。
  • 实现:仅允许合法状态流转,例如“已支付”状态的订单,若收到“支付”请求,直接返回“已支付”,不重复处理。

5.4 去重表/Redis:记录已处理请求

  • 实现:维护一张 request_log 表,记录已处理的请求 ID,处理前查询该表,避免重复执行。
  • 优势:适用于无唯一 ID 的场景(如第三方回调),通过请求参数的哈希值去重。

6. 支付流程:如何保证“支付安全”与“状态一致”?

支付流程涉及多系统协作(订单、支付、库存同步),需确保“支付成功→订单状态更新→库存扣减”的原子性,同时处理回调、幂等、异常场景。

6.1 标准支付流程

四步闭环
1. 下单:用户选择商品,后端生成订单(状态:待支付),调用第三方支付接口(如微信支付 prepay_id)。
2. 支付:用户完成支付,第三方通过 异步回调 通知商户(如微信支付回调 URL)。
3. 回调处理:验证回调签名(防伪造)→ 幂等判断(是否已处理)→ 更新订单状态(已支付)。
4. 后续操作:通过 MQ 异步处理“发货”“积分发放”等非核心流程。

6.2 回调验签:防止伪造支付通知

核心逻辑
- 第三方支付平台(如微信)会对回调参数签名(sign),商户需用 商户密钥 重新计算签名,与回调中的 sign 比对。
- 验签失败直接拒绝请求,避免伪造“支付成功”导致的损失。

6.3 异步回调的必要性

为什么不用同步回调?
- 同步回调需等待所有后续操作完成(如发货、积分),若耗时 100ms,用户会因“支付按钮卡住”而重复点击。
- 异步回调:仅处理核心“标记支付成功”,后续操作(如发货)丢到 MQ 异步执行,确保回调 100ms 内返回。

常见追问:队列挂了怎么保证支付状态一致?

双保险策略
1. DB 事务同步:支付回调时,核心状态(如订单状态)直接写入 DB,确保状态不丢失。
2. 对账补偿:定时任务扫描“已支付但未发货”的订单,重新投递 MQ 消息,确保最终一致。

7. 数据库事务与场景:如何保证“ACID”?

事务是一组操作的原子化执行(要么全成功,要么全失败),ACID 特性是数据库可靠性的基础。在高并发场景中,需根据业务选择合适的隔离级别(读未提交→串行化)。

7.1 事务的核心特性

  • 原子性(Atomicity):操作不可分割,失败则回滚(如支付失败,订单不创建)。
  • 一致性(Consistency):事务执行前后,数据满足业务规则(如库存不能为负)。
  • 隔离性(Isolation):并发事务互不干扰(通过锁或 MVCC 实现)。
  • 持久性(Durability):事务提交后,数据永久保存(如 redo log 保证崩溃后恢复)。

7.2 必须用事务的典型场景

  1. 支付流程支付扣钱生成订单更新库存 必须同成功或同失败。
  2. 账户转账:A 账户扣钱,B 账户加钱,中间任一失败则回滚。
  3. 库存扣减扣减库存创建订单 两步操作需原子性(见“秒杀”章节)。

8. 服务治理:熔断 / 限流 / 降级,保障系统弹性

当系统依赖第三方服务(如大模型 API、支付接口)时,需通过“熔断、限流、降级”保障主链路可用,避免“一损俱损”。

8.1 限流:控制请求总量

实现方式
- 令牌桶算法:按固定速率生成令牌,请求需获取令牌才能处理(如每秒 100 令牌,QPS 限制 100)。
- Redis + Lua:通过 Lua 脚本实现分布式限流(如 INCR key EX 1 NX 限制每秒请求数)。
- 网关层限流:Nginx 或 API Gateway 直接限制 IP/用户的请求频率。

8.2 熔断:下游故障时“快速失败”

核心思想:当第三方服务(如大模型 API)连续失败(如 5 次超时),触发“熔断”,直接返回缓存或默认值,给下游恢复时间。
- 状态:熔断状态(跳闸)→ 半开状态(试探性放少量请求)→ 恢复状态。
- 实现:Sentinel 或 Hystrix 通过 @HystrixCommand 注解配置“失败阈值”和“恢复时间”。

8.3 降级:非核心功能“牺牲”保主链路

场景:当系统过载时,关闭非核心功能(如“推荐商品”“用户动态”),仅保留“下单”“支付”等核心链路。
- 兜底策略:返回缓存旧值(如“推荐商品”返回热门列表)、默认回复(如“系统繁忙,请稍后重试”)。

大模型场景落地

  • 限流:按用户等级分配调用额度(如普通用户 10 次/分钟)。
  • 熔断:第三方模型超时→切换备用模型(如“豆包→千问”)。
  • 降级:非实时任务(如“生成报告”)入队列异步处理,高频结果缓存兜底。

9. 分库分表 / 读写分离:如何解决“数据爆炸”?

当单表数据量达千万/亿级时,需通过“读写分离”和“分库分表”拆分数据,提升查询性能。

9.1 读写分离:主库写、从库读

核心逻辑
- 主库:处理写操作(如订单创建、库存更新)。
- 从库:处理读操作(如商品详情、订单列表),通过主从复制同步数据。
- 延迟问题:主从复制有毫秒级延迟,强一致性读需走主库(如支付后查询订单)。

9.2 分库分表:拆分数据存储

两种拆分方式
- 垂直拆分:按业务拆分(如用户库、订单库)或按字段拆分(如订单表拆分为 orderorder_detail)。
- 水平拆分:按规则拆分单表数据(如按 user_id % 100 拆分为 100 个库,或按时间范围拆分为“2023Q1/2023Q2”)。

关键问题
- 跨库 Join:需通过分布式事务或数据冗余解决(如订单表存储用户 ID,Join 时查用户库)。
- 全局唯一 ID:通过雪花算法(Snowflake)生成唯一 ID,避免分库分表后 ID 冲突。

10. 从 0 到 1 架构设计:分层与演进

一个健壮的 AI 业务系统可分为 5 层,从接入到支撑,逐步构建高可用架构:

10.1 分层架构

  • 接入层:网关(鉴权、限流、路由)、SSE 长连接(实时推送)。
  • 应用层:业务逻辑(订单、支付、推荐)、意图路由(用户需求分类)。
  • 能力层:大模型调用(封装 API)、RAG 检索(知识增强)、工具调用(Function Calling)。
  • 数据层:MySQL(业务数据)、向量库(向量存储)、Redis(缓存/锁)。
  • 支撑层:MQ(异步通信)、监控告警(Prometheus)、日志收集(ELK)。

10.2 从 0 到 1 设计步骤

  1. 明确业务边界:梳理核心流程(如“下单→支付→发货”),拆分模块。
  2. 技术选型:确定框架(如 FastAPI/Go)、中间件(如 Redis 版本、Kafka 分区数)。
  3. 分层实现:先完成核心链路(如“下单”),再逐步扩展非核心功能。
  4. 非功能性设计:限流、监控、容灾(多可用区部署)。
  5. 迭代优化:通过压测发现瓶颈(如 Redis 内存不足→扩容),逐步优化。

11. 高频追问速答:基础技术点巩固

单例模式优点?

  • 资源复用:全局唯一实例(如 DB 连接池),避免重复创建。
  • 统一入口:通过单例封装核心逻辑(如配置管理、日志工具),简化调用。
  • 避免并发问题:如多线程下的“重复初始化”“状态不一致”。

怎么给上百个接口统一加日志/监控?

AOP 切面 + 中间件
- Python/Go:FastAPI 用 middleware 拦截请求,记录 path/method/耗时
- Java/Node.js:Spring AOP 或 Express 中间件,通过注解 @Log 自动注入日志。
- 监控:Prometheus + Grafana 采集接口耗时、错误率,告警阈值(如 500ms 超时告警)。

SSE 断线续传怎么做?

SSE(Server-Sent Events) 支持“断点续传”,核心是 Last-Event-ID
1. 服务端发送事件时带 id(如 id: 123)。
2. 客户端断开重连时,通过 Last-Event-ID: 123 告知服务端“从第 123 条事件开始”。
3. 服务端缓存流式结果(如 Redis 存 stream:123),重连后从 123 继续推送。

小结

本文系统拆解了后端系统设计的核心技术:从秒杀场景的“高并发防护”(限流、缓存、异步落库),到缓存与数据库一致性的“Cache Aside + 延迟双删”,再到分布式锁、幂等性、支付流程、服务治理等关键环节。这些技术不仅是面试考点,更是构建高可用、高并发系统的基石。掌握它们,能帮助你在面对“流量峰值”“数据爆炸”“第三方依赖”等问题时,快速定位瓶颈并设计出稳定的解决方案。后续可结合具体业务场景(如大模型 API 调用、高并发交易系统)深入实践,逐步提升架构能力。

继续阅读

全部归档
数据库与并发
数据库与并发

本文系统梳理数据库与并发核心知识:B+树因固定查询复杂度、叶节点链表支持范围查询成为主流索引;MVCC通过快照实现读写不互斥,MySQL默认可重复读隔离级别,读已提交每次新建快照;SQL优化需用索引、聚合优先、建临时表,大数据量可采用宽表和ClickHouse分区分桶;ClickHouse的列式存储与ReplacingMergeTree适合聚合与增量去重;Elasticsearch依赖倒排索引、分片副本和缓存实现高效召回;多线程需注意竞态条件与死锁,守护线程随主线程结束自动终止;经典SQL题通过自关联和分组统计筛选在≥3家网吧出现时间重叠的用户对。掌握这些知识可提升系统设计与查询优化能力。

Python 基础
Python 基础

Python核心基础8大模块:数据类型分不可变(数字、字符串、元组)与可变(列表、字典、集合),影响赋值与内存;装饰器通过函数或类动态增强行为,可用作速率限制等;迭代器实现`__next__`协议,生成器用`yield`逐步产生值,内存高效;GIL使CPU密集型任务多线程效率低,建议用多进程,I/O密集型可用多线程或异步;反射用`getattr`等动态调用,GC以引用计数为主、标记清除和分代回收为辅,上下文管理器通过`__enter__/__exit__`自动释放资源;`==`默认比较地址,`__eq__`可自定义相等逻辑;`re`模块支持匹配、替换、分割及预编译和分组;常用内置函数如`map`、`filter`、`zip`提升编码效率。理解这些基石可应对面试、优化代码并解决实际问题。

评论