当线上出现问题时,怎么做

✅线上出现问题时优先止血,防止对公司造成亏损,使用GIT版本控制回退到上一个稳定的版本,确保线上环境能为用户持续服务。

✅记录下出现问题的时间,之后结合监控指标$+$日志(traceID)$+$链路追踪,沿着网关$\longrightarrow$服务$\longrightarrow$DB/Redis/MQ/第三方逐层排查,确认是代码问题还是配置的问题

✅修复后持续观察一段时间,查看错误率是否下降并稳定,P99是否下降并稳定。最后记录好这起事故,将监控告警、降级开关、压测补齐,避免再次发生

P99

**P99(99th percentile,99 分位数)**是一个性能指标,表示:

在所有请求里,99% 的请求耗时都 ≤ P99 这个值
只有最慢的 1% 会比它更慢。

用一个很生活的例子 🌰

假设你统计 100 次接口耗时,按从快到慢排序

排序前:【100ms,100ms,…,300ms,200ms,200ms】

排序后:【100ms,100ms,…,200ms,200ms,300ms

  • 第 99 个请求的耗时 = 200ms
    P99 = 200ms
    意思是:100 次里有 99 次都不超过 200ms,只有最慢那 1 次可能更慢

监控指标+日志(traceID)+链路追踪

监控指标

用来快速判断:是服务挂了?变慢了?流量暴涨了?

网关层:QPS、4xx/5xx 错误率、P99 延迟

服务层:CPU/内存、GC 次数与耗时、线程池活跃数/队列长度

依赖层

  • MySQL:连接数、慢 SQL 数、锁等待
  • Redis:命令耗时、超时数、命中率
  • RocketMQ:Topic 堆积量、消费延迟、消费失败次数

日志(traceID)

日志是文本细节,能定位到具体接口、具体参数、具体异常堆栈。

一个请求从网关进来,会带一个唯一 ID:traceId=abc123
这个请求经过 gateway → order-service → pay-service → device-service → MQ consumer……

每一段日志都会打印同一个 traceId(或传递新的 spanId),这样你能把一条请求的全链路日志串起来。

链路追踪

链路追踪是把一次请求拆成多个 span(网关、服务、DB、Redis、MQ…),每段都有耗时。

货柜里的例子 🌰

“下单接口变慢”,链路追踪一看:

  • gateway → order-service:5ms
  • order-service → Redis:2ms
  • order-service → MySQL:800ms(慢点出现)
  • 或者 order-service → account-service:timeout 3s

你立刻就知道该去查 MySQL 慢 SQL / 锁等待,还是查下游服务超时。

三个放一起怎么用?

指标先定位是哪一层出问题(网关/服务/依赖) →
traceId 日志抓住具体失败请求看异常 →
链路追踪确定慢在哪一段(DB/Redis/Feign/MQ)并验证修复效果。


4xx/5xx错误

4xx基本是:请求发出去了,但“请求本身不对”或“权限/频率不允许”。

  • 401 Unauthorized(没登录/ token 失效)

前端:看看 Authorization 是否为空、是否过期、是否刷新 token 失败

  • 403 Forbidden(有 token 但没权限)

多是后端鉴权/角色权限问题、或者资源归属不对(A 用户访问 B 的资源)

  • 404 Not Found(路径不对/网关路由没配/环境不一致)
  • 400 / 422(参数校验失败、格式不对)

字段名、类型、必填缺失、时间格式、枚举值不对

  • 429 Too Many Requests(限流)

说明触发了网关/接口限流

5xx 基本是:请求没问题,但“服务端处理炸了”或“网关/上游依赖炸了”。

  • 500(后端代码异常)

前端能做的:

  • 把请求参数、响应体、时间点、用户信息、traceId(如果返回头有)收集好
  • 让后端用 traceId 查日志栈:NullPointer、SQL 异常、序列化异常等
  • 502 Bad Gateway(网关拿不到上游正常响应)

常见原因:

  • 上游服务挂了/没实例
  • 网关到上游网络不通
  • 503 Service Unavailable(服务不可用/被摘流量/熔断)

服务重启、发布中、熔断、容器不可用

  • 504 Gateway Timeout(上游太慢超时)

下游慢:DB 慢 SQL、锁等待、Redis 超时、MQ 堆积导致处理慢

处理方式:设置合理的timeout


大量数据导入 Mysql 失效排查

✅首先看是插入失败还是插入超时?

  • 插入失败:比如唯一键冲突、字段类型不匹配、外键约束等
  • 插入超时:长时间无响应、连接超时、锁等待

✅超时解决办法

超时往往和单次写入量过大、单事务过长有关

采用分批插入降低单次压力,例如每批从5000条降到1000条,并在每批结束后提交

✅报错的核心原因

大量导入常见是大事务导致:

  • undo log 膨胀(回滚段变大)
  • 事务持锁时间变长,引发等待/超时
  • 资源占用上升,甚至影响整体性能

MySQL调优

框架

以下内容是挡在MySQL前面的,也就是说读取数据的时候先走它们,再走MySQL

  • 读写分离
  • 主从复制
  • Redis缓存
  • Java中的数据结构:在项目启动的时候,先从数据库里查询常用的数据存储到,如List,Map中,使用的时候直接获取
  • 数据量大——>TIDB,数据量更大——>分库分表(数据量大的时候分表,并发高的时候分库)

硬件

  1. 内存大于32g

MySQL存在 buffer pool ,对于数据修改,先修改内存中的数据,再将内存中的数据刷入磁盘中

buffer pool 占MySQL内存的 75% ~ 80% 之间

  1. CPU

MySQL的连接数(Druid连接池),最优情况下:CPU核心数 * 2(如果是固态硬盘还可以 + 1)


SQL优化

  1. 禁止使用 Select *

会读取多余字段

失去覆盖索引的优化机会,查询缓慢


  1. 共享数据

比如在一个业务中两个接口都同时查询一条数据,并且A接口要调用B接口,此时可以只在B接口查询,将结果通过参数传递给A


  1. 深分页的问题如何解决?

什么是深分页?

使用如下SQL语句查询满足update_time条件的数据,跳过前100000条,取出10条数据

1
select * from account where update_time >= '2025-09-23 12:30:59' order by update_time limit 100000, 10; 

由于这条SQL语句执行顺序是 from -> where -> select -> order by -> limit,在分页之前,比如满足 update_time >= ‘2025-09-23 12:30:59’ 的数据有80万条,此时就会触发80万次回表查询,再将查询到的结果进行limit分页,这样做很耗时间。

解决方法

  • 选择上 / 下一页

注意】id需要为整形,并且自增的

前端需要将最后一条数据的id发过来,以及查询的条数就可以加快查询了

1
select * from account where id >= 100000 limit 10;
  • 手动选择第 N 页

子查询优化

注意】需要一个查询条件,且有索引(update_time)

1
select * from (select id from account o where o.update_time >= '2025-09-23 12:30:59' order by o.update_time limit 100000, 10) as temp inner join account on temp.id = account.id;

在子查询中通过二级索引查找满足条件的10条数据,不用进行回表查询

在连表查询中,通过匹配 id 获取满足条件的10条数据

此时只需要经历10次回表查询


  1. 更新什么就改什么,而不是将全部字段update回去

update的时候,将一条数据所有信息查询出来放在一个对象中,只更新其中一个数据,再将整个对象update到数据库中,在高并发场景下,会造成数据不同步问题

比如,我第一个方法查出一条数据所有信息为a = 1,b = 1,我只修改b的值,a = 1, b = 2;这个时候另一个方法也查出这条数据的所有信息为a = 1,b = 1,此时它只修改a的值为a = 2,b = 1;第一个方法先update,将b的值改变;
第二个方法再update,a的值改变,同时将b的值恢复,这就会造成数据不同步


  1. 日期类型选择
  • datetime:8字节(占用大)
  • timestamp:4字节(可以显示1970-1-1到2038的时间戳,推荐)
  • data:3字节(显示时间不完全)

  1. 字符类型选择
  • 数字范围很小用tinyint占1个字节,相比较于int占4个字节
  • 如果确定字符数量长度,用char(1)性别、char(11)电话号码

系统配置

  1. Redo log刷盘策略innodb_flush_log_at_trx_commit取值(0/1/2)
  • 0:每隔一秒把log buffer刷到文件系统中
  • 1:每次事务提交的时候,都把log buffer刷到文件系统中,立即刷新到磁盘上去
  • 2:每次事务提交的时候,都把log buffer刷到系统文件中,但不会立即写入磁盘
  1. bin log刷盘策略sync_binlog,取值(0/1/N)
  • 0:依赖操作系统定期同步,性能最佳,但崩溃时可能丢失多个事务
  • 1:每次事务提交后同步,确保崩溃时最多丢失一个事务,安全性最高,但性能耗损最大
  • N:每提交N个事务后同步一次,平衡性能和数据安全

慢SQL查询日志

  1. slow_query_log
  2. explain字段
1
explain select * from orders where status = '1';

看查询语句的效率,有如下几个参数

  • key:实际使用的索引名,如果是NULL就没有索引
  • key_len:使用索引的长度
  • rows:预计扫描的行数
  • type:访问类型

访问层级:System > Const > Eq_ref > Ref > Range > Index > All

  1. System:表中只有一行数据,直接返回
  2. const:查询命中主键唯一索引的全部列,性能非常好,只会读取一次索引

比如select name from user where id = 3;

  1. eq_ref:只会出现在多表连接(JOIN)时,而且是被驱动表(也叫第二张表、右表)用主键唯一索引进行等值匹配,并且这些索引列都不能为 NULL

比如Select * from user join orders on user.id = orders.user_id;o.user_id是唯一索引

  1. ref:查询通过非唯一索引(普通 KEY/INDEX)、唯一索引的非全部列,或联合索引的最左前缀列等值匹配= 条件)

  2. range:索引范围查找,条件是 <, >, between, like ‘abc%’ 等,使用索引列的范围条件(遵守最左前缀原则)

比如:SELECT * FROM orders WHERE order_date BETWEEN '2024-01-01' AND '2024-01-31';

  1. index:扫描整个索引树,而不是表的所有行,查询只用到索引列(覆盖索引),不必回表

比如:select id from orders;,如果id是主键,这就是index扫描

  1. all:没有任何可用索引,直接从磁盘扫描
  • Extra:额外信息
  1. Using where:查询走了索引,但是仍要在取出的数据上做一次where查询

使用了索引跳跃,在索引层做了条件判断

1
2
3
4
5
6
7
8
9
10
user
id primary key
name, 
age, 
index idx_age(age)
)

select * from user where age > 20 and name = 'tom';

# age走了索引,name = 'tom'取出数据后过滤 —> Using where
  1. Using index:使用了覆盖索引
1
2
3
4
5
6
7
8
user
id primary key
name, 
age, 
index idx_name(name)
)

select name from user where name = 'jasonqian';
  1. Using where; Using index;
1
2
3
4
select age, name from user where age > 20

# 索引idx_age_name覆盖了age和name —> Using index
# 条件age > 20需要范围扫描,进行过滤 —> Using where
  1. Using filesort:排序没法完全靠索引顺序完成,必须额外做排序操作
1
2
3
4
5
6
7
8
# 触发场景
# order by 列没有用上索引
index idx_age(age)
select * from user order by name;

# order by 列不符合最左前缀原则
index idx_name_age(name, age)
select * from user order by age

索引

  1. 最左前缀原则
  2. 索引覆盖
  3. 索引下推
  4. 主键索引尽量使用自增主键,减少页分裂

因为InnoDB索引底层数据结构为B+树,它需要保证内部有序,通过主键自增的方式,让b+树增加新的数据时,永远加在最后一个叶子结点尾部

  1. 不使用like
  2. 版本8.0之前,在使用order by的时候,不要既使用asc,也用desc

在8.0之前,索引排序方向必须一致,一旦order by混用了asc和desc,没法用B+树索引顺序,只能走filesort

  1. 索引列不做函数运算、不计算
  2. or两边其中一个没有索引就会导致全表扫描

在第一个事务的update中,如果where 后面的字段没有索引,会导致第二个事务update锁表吗?

第一个事务中:update table set name = 'tmo' where num = 1;

第二个事务中:update table set name = 'tmo' where num = 1;

这要分情况:

  • 如果是读已提交RC隔离级别下,不会锁表
  • 如果是可重复读RR隔离级别下,会锁表

JVM调优

✅做过。

  1. 最初设备就100台

  2. 后来设备越来越多,到了600台,通过排查(使用Leader搭建的可视化工具检查dump文件)发现持续发心跳 发重量 会导致通讯服务中的 Minor GC 非常频繁,而且晋升到老年代的速率很高(Survivor 装不下导致提前晋升),老年代增长很快,进而触发 Full GC。

  3. CPU处理速度变慢或导致频繁的full GC

怎么解决?

  1. 首先把每个通信服务我们最多让它连接300台~500台设备,新增的设备连到新的通信服务上。

  2. 通信服务做了集群,一共5台,缓存记录了每台设备连接的是哪些通信服务

  3. 正常情况下,老年代 : 新生代 = 2:1 将其调成 老年代:新生代 = 1:1。这是因为我们的对象大多是短命的,把 新生代 做大能降低 Minor GC 频率,让短命对象在 Eden 直接死掉,减少 GC 压力。

-XX:NewRatio=2 代表 老年代:新生代 = 2:1(默认常见值之一)

-XX:NewRatio=1 代表 老年代:新生代 = 1:1

  1. 再将新生代的 Eden : Survivor : Survivor = 8 : 1 : 1 改成 Eden : Survivor : Survivor = 3 : 1 : 1。这是因为有一部分对象会跨过 1~2 次 GC,如果 Survivor 太小就会被迫提前晋升到 Old。把 Survivor 做大可以降低晋升率,让这类对象在 新生代 里自然消亡。

-XX:SurvivorRatio=8 代表 Eden : Survivor = 8 : 1(两个 Survivor 各 1)

-XX:SurvivorRatio=3 代表 Eden : Survivor = 3 : 1

修改以后,查看 GC 日志里的 promotion rate(晋升量)、Old 使用曲线和 Full GC 次数,确认 Survivor 调整确实降低了提前晋升。

GC 日志:时间序列的“过程记录”(频率、停顿、内存变化趋势)

Heap Dump:某一时刻的“静态快照”(对象分布、引用链、泄漏定位)

用一个小例子帮你建立直觉 🌰

你发现服务卡顿:

  • 你先看 GC 日志:发现 Full GC 很频繁,每次停顿 1s+
    → 说明“GC 行为有问题”,但还不知道“是谁占内存”。
  • 然后打 Heap Dump:发现某个 Map 里堆了几百万条设备心跳对象,被某个缓存引用着没释放
    → 你就知道“是谁导致老年代涨”,可以去改代码或限流。

熔断、降级

✅熔断(Circuit Breaker)是什么意思?

熔断 = 发现下游不行了,就先别再一直打它了,先“断开”一段时间
目的:保护自己 + 防止雪崩

为什么需要熔断?

如果 order-service → account-service 调用开始变慢/超时:

  • 你还一直疯狂重试、一直打过去
  • 线程池/连接池被占满
  • 最后把 order-service 自己也拖死,扩大故障范围(雪崩)

✅降级(Degrade / Fallback)是什么意思?

降级 = 下游不行时,我仍然给用户一个“可接受的替代结果”,保证核心流程能走下去
目的:保核心功能可用

货柜场景的降级例子 🌰

  • 结算服务(称重/计费)抖动:降级为“订单先进入 待结算 状态”,先告诉用户“取货完成”,后续异步补结算

如何做限流?

✅前端做一个验证,如滑动窗口、输入数字减少请求次数

✅如果前端通过接口访问,后端可以做Redis滑动窗口全局限流注解


Redis滑动窗口

使用ZSET(有序集合)对每个限流维度(如userId+api)维护一个ZSET

  • score:请求时间戳
  • member:请求唯一id

每次请求进来的时候使用Lua脚本做:删除窗口外的旧请求、统计窗口内请求数、如果小于limit允许通过并将请求id加入、设置过期时间

✅ 优点

  • 精确:严格控制“最近 N 秒/毫秒”
  • 平滑:不会出现固定窗口的边界突刺
  • 易落地:Redis + Lua 即可

⚠️ 缺点

  • 内存开销:每个请求都要写一条记录(QPS 很高会很大)
  • 性能开销:ZSET 操作是 O(logN),N 是窗口内请求数
    (窗口大、QPS 高时压力上来)

全局限流注解

一开始我们在秒杀接口上加了全局限流注解,用 AOP 统一拦截,按 couponId + ip(或 userId) 生成 key,通过 Redis SET NX PX 做冷却型限流,保证多实例一致。压测时发现秒杀流量会让 Redis QPS 明显升高,热点 key 造成延迟抖动,而 Redis 又是其他业务强依赖组件,容易被连带影响。
后来我们做了优化:把限流做成两级——本地 Caffeine 先挡洪峰,用 AtomicLong nextAllowedAt + CAS 保证并发正确,

  • 如果now < nextAllowedAt就拦截
  • 否则放行,更新nextAllowedAt = now + 800ms

并设置过期时间和最大容量防止 key 膨胀;必要时再配合 Redis 做全局一致限流。这样 Redis 压力显著下降,接口 P99 更稳定,整体抗压能力更好。

Caffeine

  • 并发性能好(比自己写清理线程的 Map 更稳)
  • 支持 expireAfterWrite(自动过期,防止 key 堆积内存泄漏)
  • 支持 maximumSize(防止被刷出海量 key)

在工作中做过的挑战是什么?

在设置秒杀优惠券的时候,如何抗住高并发?

秒杀优惠券场景最大挑战是高并发和恶意请求。前端我们先做了按钮防抖/禁用重复点击,减少误触,但核心还是后端要兜住绕过。

一开始我们在秒杀接口上加了全局限流注解,用 AOP 统一拦截,按 couponId + ip(或 userId) 生成 key,通过 Redis SET NX PX 做冷却型限流,保证多实例一致。压测时发现秒杀流量会让 Redis QPS 明显升高,热点 key 造成延迟抖动,而 Redis 又是其他业务强依赖组件,容易被连带影响。
后来我们做了优化:把限流做成两级——本地 Caffeine 先挡洪峰,用 AtomicLong nextAllowedAt + CAS 保证并发正确,

  • 如果now < nextAllowedAt就拦截
  • 否则放行,更新nextAllowedAt = now + 800ms

并设置过期时间和最大容量防止 key 膨胀;必要时再配合 Redis 做全局一致限流。这样 Redis 压力显著下降,接口 P99 更稳定,整体抗压能力更好。


在工作中遇见的困难有哪些?怎么解决的?