B站服务器开发一二面

85 阅读19分钟

今天分享一下训练营内部朋友在B站游戏服务器开发面试的详解,

主要整理了问到的技术问题,项目介绍类问题去掉了,覆盖分布式、中间件、数据库、并发控制等知识点,大家可以参考学习一下。

一面

1. 项目最终一致性的设计思路

核心思路:基于“事务消息+重试机制+幂等性”实现,优先选择低侵入性方案,适用于订单支付后库存、积分、日志等跨服务同步场景。

具体实现(以订单支付为例):

  1. 本地事务与消息发送原子性:使用“本地消息表+定时任务”或 RocketMQ 事务消息。比如用 RocketMQ 时,先执行本地订单更新(状态改为“待支付”→“已支付”),成功后提交事务消息,失败则回滚本地事务。
  2. 消息消费与重试:下游服务(库存、积分)订阅事务消息,消费成功则更新自身状态,失败则触发 MQ 重试(阶梯式重试:10s/30s/5min,避免瞬时故障)。
  3. 幂等性保障:每个消息携带唯一 ID(如订单号+流水号),下游服务消费前先查“消息消费记录表”,已消费则直接返回成功,未消费则执行逻辑。
  4. 最终兜底:定时任务扫描“未同步成功”的订单,主动触发补偿逻辑(如调用库存服务接口重试),确保最终所有服务状态一致。

2. 项目异步设计的思路

核心思路:解耦服务依赖、提高吞吐量,优先用“消息队列+Go 协程”组合,覆盖跨服务异步和本地异步场景。

具体设计:

  1. 跨服务异步(解耦):用 Kafka/RocketMQ 做异步通信,比如用户注册后,同步发送“注册成功”消息,下游服务(短信、邮件、日志)订阅消费,主流程无需等待。
  2. 本地异步(提效):用 Go 协程处理无依赖的本地任务,比如订单创建后,启动协程异步生成订单快照、记录操作日志,通过 sync.WaitGroup 控制协程等待(如需等待结果)或 channel 传递结果。
  3. 关键保障:
    • 幂等性:同最终一致性的消息 ID 校验;
    • 超时处理:用 context.WithTimeout 控制协程执行时间,避免阻塞;
    • 错误处理:协程 panic 捕获(defer recover())、消息消费失败入死信队列,定期复盘;
    • 结果回调:如需同步异步结果,用“回调函数+channel”或“状态轮询”(如前端轮询订单支付状态)。

项目落地:游戏充值接口通过异步化改造,吞吐量从 500 QPS 提升至 3000 QPS,响应时间从 300ms 降至 50ms。

3. 消息队列怎么消费不同标签的信息

以 RocketMQ(Tag 机制)和 Kafka(Topic+Partition 二级分类)为例,核心是“** broker 端过滤+消费端订阅**”:

  1. 标签(Tag)设计:Tag 是消息的二级分类,基于业务场景划分(如订单消息:ORDER_PAID/ORDER_CANCELLED/ORDER_REFUNDED)。
  2. 消费端订阅逻辑(Go 实现):
    • RocketMQ:使用 Go SDK(如 github.com/apache/rocketmq-client-go),在创建消费者时,通过 ConsumerOption 指定订阅的 Tag,格式为 Topic:Tag1||Tag2(多 Tag 用 || 分隔),Broker 会仅将匹配 Tag 的消息投递给消费者。
    • Kafka:无原生 Tag,但可通过“Topic+消息头”模拟,消费端读取消息头中的 tag 字段过滤,或直接按 Tag 拆分 Topic(如 order_paid_topic/order_cancelled_topic),更高效。
  3. 优势:Broker 端过滤减少无效消息传输,提升消费效率;消费端可灵活订阅所需 Tag,实现业务解耦。

4. Golang 的线程池、协程池的使用?比如 running buffer

Go 无内置线程池/协程池,但协程(Goroutine)轻量(初始栈 2KB),可通过 channel 手动实现协程池,核心是“控制并发数+任务调度”:

(1)协程池核心设计
  • 核心组件:任务队列(taskChan)、worker 协程池、并发控制(maxWorkers)、运行状态标识(running buffer,即当前活跃 worker 数)。
  • 实现步骤(Go 代码简化):
    type Task func() error
    
    type Pool struct {
        taskChan   chan Task       // 任务队列
        maxWorkers int             // 最大并发数
        running    int32           //  当前运行的worker数(原子变量,避免竞态)
        ctx        context.Context
        cancel     context.CancelFunc
    }
    
    // 初始化协程池
    func NewPool(maxWorkers int) *Pool {
        ctx, cancel := context.WithCancel(context.Background())
        pool := &Pool{
            taskChan:   make(chan Task, 100), // 任务队列缓冲
            maxWorkers: maxWorkers,
            ctx:        ctx,
            cancel:     cancel,
        }
        // 启动worker
        for i := 0; i < maxWorkers; i++ {
            go pool.worker()
        }
        return pool
    }
    
    // worker协程:循环消费任务
    func (p *Pool) worker() {
        defer atomic.AddInt32(&p.running, -1)
        atomic.AddInt32(&p.running, 1)
        for {
            select {
            case <-p.ctx.Done():
                return
            case task, ok := <-p.taskChan:
                if !ok {
                    return
                }
                _ = task() // 执行任务
            }
        }
    }
    
    // 提交任务
    func (p *Pool) Submit(task Task) error {
        select {
        case <-p.ctx.Done():
            return fmt.Errorf("pool closed")
        case p.taskChan <- task:
            return nil
        }
    }
    
(2)关键概念与使用场景
  • running buffer:用 atomic.Int32 维护当前运行的 worker 数,可用于监控协程池负载(如通过 Prometheus 暴露指标)。
  • 使用场景:高并发 I/O 操作(如批量调用第三方接口、数据库批量写入)、避免无限制创建协程导致的内存溢出。
  • 注意点:任务队列需设置缓冲(避免提交任务阻塞)、worker 优雅退出(通过 context 控制)、错误处理(任务执行失败需记录日志或重试)。

5. 用的什么中间件监听数据库 binlog

项目中用 Canal 监听 MySQL binlog,核心是“模拟 MySQL 从库同步协议,解析 binlog 并推送变更”:

  1. 工作流程:
    • Canal 伪装成 MySQL 从库,向主库发送 dump 命令,获取 binlog 日志;
    • 解析 binlog(支持 row 格式,记录具体数据变更),提取表名、操作类型(insert/update/delete)、变更前后数据;
    • 通过 Canal Client(Go SDK:github.com/alibaba/canal-go)订阅变更事件,推送至业务逻辑(如同步数据到 Redis、ES,或触发跨服务通知)。
  2. 优势:轻量、低侵入(无需修改业务代码)、支持高可用部署(Canal Server 集群)。

6. Redis 常用的数据结构

数据结构核心用途项目应用场景
String简单键值存储、计数器存储玩家验证码(key=player:{id}:code)、游戏在线人数计数(INCR/DECR)
Hash复杂对象存储(字段-值映射)存储玩家信息(key=player:{id},field=name/level/gold)、商品属性
List队列、栈、消息列表游戏公告队列(LPUSH/RPOP)、玩家邮件列表
Set去重、交集/并集运算玩家好友关系(SADD/SISMEMBER)、抽奖活动去重(避免重复中奖)
Sorted Set有序排序、排行榜游戏战力排行榜(ZADD/ZRANGE)、限时活动积分排名
Bitmap位运算、布尔值存储玩家签到记录(key=sign:{date},bit=playerID,1=已签到)
Geo地理位置计算游戏附近玩家查找(GEORADIUS)

进阶用法:Hash 用 HSCAN 避免大 key 阻塞、Sorted Set 用 ZREMRANGEBYRANK 维护TopN排行榜、String 用 SETEX 实现过期缓存。

7. ETCD 的作用

ETCD 是分布式键值存储(基于 Raft 协议),核心作用是“分布式一致性保障”,项目中主要用于 3 个场景:

  1. 服务注册与发现:微服务(如游戏网关、战斗服、道具服)启动时向 ETCD 注册(key=/services/{serviceName}/{instanceID},value=服务地址+元数据),客户端通过 ETCD 的 Watch 机制监听服务变更,动态获取可用实例(配合 gRPC 负载均衡)。
  2. 配置中心:存储全局配置(如数据库连接池大小、活动开关、限流阈值),通过 Watch 机制实现配置动态更新(无需重启服务),Go 中用 etcd/clientv3 订阅配置变更。
  3. 分布式锁:基于 ETCD 的 Lease(租约)+ CAS 操作实现,用于跨服务并发控制(如游戏跨服活动报名、分布式任务调度),避免死锁(租约过期自动释放锁)。

优势:强一致性、高可用(集群部署)、轻量、支持 TTL 过期键。

8. 百库百表分库分表思路(玩家场景)

核心思路:水平分片(按玩家 ID 哈希分片),目标是分散数据压力、提升查询效率,适配百万级玩家数据存储:

  1. 分片维度选择:按玩家 ID 分片(玩家操作自身数据时,可直接路由到对应库表,无跨库联查)。
  2. 分片策略:
    • 分库分表规则:100 库 × 100 表 = 10000 张表。玩家 ID 经过哈希计算(如 hash(playerID) % 100)得到库索引,hash(playerID) / 100 % 100 得到表索引,最终路由到 db{库索引}.t_player_{表索引}
    • 哈希算法:用一致性哈希(带虚拟节点),支持后续扩容(新增库表时仅迁移部分数据,影响范围小)。
  3. 中间件选型:ShardingSphere-JDBC(Go 项目中用 shardingsphere-go),透明化分库分表逻辑(业务代码无需关注分片规则,直接操作逻辑表)。
  4. 关键问题解决:
    • 全局 ID:用雪花算法(Snowflake)生成唯一订单号/玩家 ID,避免分库分表后 ID 冲突。
    • 跨库查询:避免跨库联查,通过“宽表冗余”(如玩家订单表冗余玩家基础信息)或“应用层聚合”(先查各库数据,再在服务端合并)。
    • 扩容方案:新增库表时,基于一致性哈希迁移旧数据,双写新旧库一段时间(确保数据一致),再切换到新库表。

项目落地:游戏玩家中心存储 500 万玩家数据,分 100 库 100 表,单表数据量控制在 5000 以内,查询响应时间稳定在 10ms 内。

二面

1. 压测时遇到的性能瓶颈及解决

压测工具:用 k6(Go 编写,高并发支持)+ Prometheus+Grafana 监控指标(QPS、响应时间、CPU/内存/网络),遇到的核心瓶颈及解决方案:

瓶颈类型现象排查方式解决方案
数据库慢查询接口响应时间>500ms,MySQL CPU 100%EXPLAIN 分析 SQL,慢查询日志1. 给订单表添加联合索引(player_id+create_time);2. 分页查询优化(用游标代替 limit offset);3. 读写分离(读请求路由到从库)
Redis 缓存穿透大量请求穿透到数据库,Redis 命中率<80%Redis 监控面板查看命中率1. 无效 key 缓存空值(SETEX key 3600 "");2. 布隆过滤器(RedisBloom)过滤不存在的玩家 ID
协程泄露内存持续增长,协程数>10wpprof 分析 goroutine 栈1. 协程池控制并发数(maxWorkers=100);2. 用 context.WithTimeout 控制协程生命周期,避免无限阻塞
网络瓶颈跨服务调用延迟>200mstcpdump 抓包,链路追踪(Jaeger)1. 服务本地缓存热点数据(如活动配置);2. gRPC 连接池优化(复用连接,减少握手开销)

优化结果:接口 QPS 从 800 提升至 5000,响应时间稳定在 50-80ms,CPU 使用率控制在 70% 以内。

2. MySQL 相关优化

从“索引、SQL、配置、架构”四层优化,结合项目实践:

  1. 索引优化:
    • 核心原则:给查询频繁的字段建索引,避免过度索引(影响写入性能);
    • 实践:玩家订单表(player_id、create_time、status)建联合索引,覆盖查询(select id, amount from t_order where player_id=? and status=? order by create_time desc),避免回表。
  2. SQL 优化:
    • 避免 select *(只查需要的字段)、避免 or(用 union 代替)、子查询转 join;
    • 实践:将“查询玩家近 30 天订单并关联商品信息”的子查询,改为 join 查询,执行时间从 300ms 降至 50ms。
  3. 配置优化:
    • innodb_buffer_pool_size = 物理内存的 50%-70%(缓存数据和索引,减少磁盘 I/O);
    • max_connections = 2000(适配高并发场景);
    • 关闭 binlog 或设置为 row 格式(减少 binlog 体积,提高写入性能)。
  4. 架构优化:
    • 主从复制(一主两从),读请求分流到从库(通过 ShardingSphere-JDBC 实现读写分离);
    • 分库分表(如玩家表、订单表),分散单库单表压力。

3. 实际项目中发现 MySQL 查询瓶颈的方法

核心是“监控+日志+执行计划”三位一体,步骤如下:

  1. 慢查询日志定位:开启 MySQL 慢查询日志(slow_query_log=1long_query_time=1),捕获执行时间>1s 的 SQL,定期分析日志(用 pt-query-digest 工具汇总)。
  2. 执行计划分析:对慢查询用 EXPLAIN 分析,重点看 type(索引类型,如 ref、range 优于 all)、key(是否使用索引)、rows(扫描行数,越少越好)、Extra(是否 Using filesort/Using temporary,需优化)。
  3. 实时监控:通过 Prometheus+Grafana 监控 MySQL 指标(slow_queries 慢查询数、innodb_rows_read 扫描行数、Threads_running 运行线程数),设置阈值告警(如慢查询数>10 触发告警)。
  4. 业务日志关联:在应用日志中记录 SQL 执行时间(如 Go 中用 sqlx 拦截器),当接口响应变慢时,直接定位到耗时 SQL。

项目案例:通过慢查询日志发现“玩家累计充值金额查询”SQL 未走索引,扫描全表(rows=50w),用 EXPLAIN 分析后,给 player_id 建索引,查询时间从 1.2s 降至 8ms。

4. 分布式系统 100 台服务器,玩家报错的处理流程

核心思路:快速定位故障范围→精准排查根因→临时止损→永久修复,步骤如下:

  1. 收集报错信息:让玩家提供“报错提示(如‘支付失败’)、操作时间、玩家 ID、服务器区服”,前端同时上报报错时的 traceID(链路追踪 ID)。
  2. 定位故障服务与节点:
    • 通过 traceID 在 Jaeger 中查询跨服务调用链路,确认是哪个服务(如支付服、订单服)报错;
    • 在 ELK 日志平台中,按“traceID+玩家 ID+时间范围”过滤日志,找到报错的服务器节点(IP+端口)。
  3. 排查节点问题:
    • 应用日志:查看该节点的错误堆栈,定位代码层面问题(如空指针、数据库连接超时);
    • 系统监控:查看节点的 CPU、内存、磁盘 I/O、网络(用 Prometheus+Grafana),是否存在资源耗尽;
    • 依赖服务:检查该节点依赖的数据库、Redis、MQ 是否正常(如 Redis 连接超时、数据库主从切换)。
  4. 临时止损:
    • 若单节点故障:通过负载均衡下线该节点,将流量转发到其他健康节点;
    • 若服务级故障:触发熔断(如用 Hystrix/Resilience4j),返回友好提示(“系统临时维护,请稍后再试”),避免雪崩。
  5. 永久修复与复盘:
    • 修复代码 bug(如空指针判断、重试机制优化);
    • 优化监控告警(补充关键链路告警);
    • 复盘会议,总结故障原因(如“未处理 Redis 连接超时”),避免同类问题。

5. 如何定位日志

基于“分布式日志架构+链路追踪”,实现日志快速定位,架构:ELK(Elasticsearch+Logstash+Kibana)+ 链路追踪(Jaeger):

  1. 日志规范:
    • 统一日志格式(JSON 格式),包含核心字段:traceID(链路追踪 ID)、spanIDserviceName(服务名)、instanceIP(节点 IP)、playerID(玩家 ID)、time(时间戳)、level(日志级别)、msg(日志内容)、stack(错误堆栈)。
    • 链路追踪透传:用 gRPC 拦截器或 HTTP 中间件,在服务间传递 traceID,确保同一请求的所有日志都携带相同 traceID
  2. 定位步骤:
    • 玩家报错后,获取 traceID(从前端或玩家提供的报错信息中提取);
    • 打开 Kibana,在索引中按 traceID:xxx 过滤,获取该请求的所有日志(从网关→业务服→依赖服务);
    • 按时间排序日志,找到报错节点的错误堆栈,定位问题(如“支付服调用微信支付接口超时”);
    • 结合 Jaeger 查看该 traceID 的调用链路,确认超时环节(如微信支付接口响应时间>3s)。

项目落地:通过该方案,将日志定位时间从 30 分钟缩短至 5 分钟,大幅提升故障排查效率。

6. 超买超卖的订单处理

核心是“并发控制+原子操作”,基于 Redis+MySQL 实现双重保障:

  1. 方案一:Redis 分布式锁+库存预扣减(高并发场景首选)

    • 锁 key:lock:goods:{goodsID}(同一商品共享一把锁);
    • 流程:
      1. 玩家下单时,用 Redis SET NX EX 命令获取锁(SET lock:goods:123 1 EX 10 NX);
      2. 获取锁成功后,查询 Redis 库存(GET goods:stock:123),库存不足则返回“商品已售罄”;
      3. 库存充足则预扣减(DECR goods:stock:123),释放锁(DEL lock:goods:123);
      4. 预扣减成功后,异步写入数据库(订单表+库存表),数据库库存表加行锁(select stock from t_goods where id=? for update),确保最终库存一致。
    • 注意:锁超时时间需大于业务执行时间,避免死锁;用 Lua 脚本保证“查库存+扣库存”原子性。
  2. 方案二:MySQL 乐观锁(低并发场景,无锁竞争)

    • 库存表添加 version 字段;
    • 扣库存 SQL:update t_goods set stock=stock-1, version=version+1 where id=? and stock>=1 and version=?
    • 执行后判断影响行数,若为 0 则说明库存不足或已被其他请求扣减,返回“操作失败”。

项目落地:游戏限时抢购活动用方案一,支持 1w+ QPS 并发下单,超买超卖率为 0,库存一致性 100%。

7. 并发场景避免二次执行(如重复发货)

核心是“幂等性设计”,结合业务场景选择以下方案:

  1. 方案一:唯一请求 ID(客户端层面)

    • 客户端(如游戏客户端)生成唯一请求 ID(UUID),每次请求携带该 ID;
    • 服务端接收请求后,先查 Redis:EXISTS request:id:{requestID},存在则返回“操作已执行”,不存在则执行业务逻辑;
    • 业务逻辑执行成功后,将请求 ID 存入 Redis(SET request:id:{requestID} 1 EX 3600),过期时间设为业务操作有效时间。
  2. 方案二:业务唯一键(数据库层面)

    • 订单表创建唯一索引:UNIQUE KEY uk_player_goods (player_id, goods_id, activity_id)(同一玩家同一活动同一商品只能创建一次订单);
    • 重复请求时,数据库会抛出 Duplicate key error,服务端捕获后返回“操作已执行”。
  3. 方案三:分布式锁(服务端层面)

    • 锁 key:lock:player:{playerID}:goods:{goodsID}(同一玩家同一商品的操作共享一把锁);
    • 只有获取锁的请求能执行业务逻辑,其他请求等待或直接返回,避免并发执行。

项目落地:游戏道具发放用“方案一+方案二”,既通过请求 ID 快速拦截重复请求,又通过数据库唯一索引兜底,确保无二次发货。

8. 支付体系的回调

支付回调是支付平台(微信/支付宝)向商户服务器发送的异步支付结果通知,核心流程:“签名验证+幂等处理+订单更新+响应确认”:

  1. 完整流程:

    • 回调配置:在支付平台(如微信支付商户平台)配置回调地址(必须 HTTPS,公网可访问);
    • 回调触发:用户支付成功后,支付平台向回调地址发送 POST 请求,参数包含:支付流水号、订单号、支付金额、签名等;
    • 签名验证:服务端用商户密钥验证参数签名(如微信支付的 HMAC-SHA256 签名),确保请求来自官方,防止伪造;
    • 幂等处理:通过订单号查询本地订单状态,若已处理(如“已支付”),直接返回成功响应;
    • 业务处理:未处理则更新订单状态为“已支付”,执行后续逻辑(扣库存、发道具、加积分);
    • 响应确认:向支付平台返回指定格式的成功响应(如微信支付返回 <xml><return_code><![CDATA[SUCCESS]]></return_code></xml>),否则支付平台会阶梯式重试(如 15s/30s/1min/2min/5min/10min/30min/1h/2h/6h/15h,共 11 次)。
  2. 关键注意点:

    • 签名验证:必须验证,避免恶意回调;
    • 幂等处理:支付平台会重试,必须保证回调处理幂等;
    • 日志记录:详细记录回调参数、处理结果,便于排查问题;
    • 超时处理:回调处理时间需<10s,避免支付平台判定超时重试。

9. 仅用 Redis 和 MySQL 防止多个请求反复执行

核心是“Redis 原子操作快速拦截+MySQL 唯一索引兜底”,无需额外中间件:

  1. 实现方案(以玩家购买商品为例):

    • 步骤 1:Redis 原子判断+锁定(快速拦截)
      • 玩家发起购买请求时,服务端执行 Redis 命令:SETNX lock:player:{playerID}:goods:{goodsID} 1 EX 30(30s 过期,避免死锁);
      • 若返回 1(获取锁成功),则继续执行;若返回 0(已被其他请求锁定),则返回“操作中,请稍后再试”。
    • 步骤 2:MySQL 唯一索引兜底(防止 Redis 宕机)
      • 订单表创建唯一索引:uk_player_goods (player_id, goods_id),确保同一玩家同一商品只能创建一次订单;
      • 执行订单插入 SQL:insert into t_order (player_id, goods_id, amount, status) values (?, ?, ?, ?)
      • 若插入成功(影响行数=1),则执行后续逻辑(扣库存、发道具);若抛出 Duplicate key error,则返回“操作已执行”。
    • 步骤 3:释放锁
      • 订单创建成功或失败后,执行 DEL lock:player:{playerID}:goods:{goodsID} 释放锁(或等待自动过期)。
  2. 优势:

    • Redis 层面快速拦截高并发重复请求,减少数据库压力;
    • MySQL 唯一索引兜底,即使 Redis 宕机,也能防止重复执行;
    • 无额外中间件依赖,部署简单。

项目落地:游戏内玩家购买月卡场景用该方案,支持 5k+ QPS 并发请求,无重复购买、重复发货问题。

欢迎关注 ❤

我们搞了很多免费的面试真题共享群,互通有无,一起刷题进步。

没准能让你能刷到自己意向公司的最新面试题呢。

感兴趣的朋友们可以加我微信:wangzhongyang1993,备注:面试群。