很厉害的面试题

2025-11-07
面试学习

面试题

1. MySQL 表里只有 1 条数据,索引高度为几层?如果再加 1 条数据呢?什么时候变成 2 层?

  • 1 条数据时,索引高度为 1 层(即只有根页)。 在 InnoDB 中,数据存储在 B+ 树结构的聚簇索引上。当表数据极少时,所有数据都存放在根节点(Root Page)中,此时树的高度为 1。查询一次磁盘 I/O 即可找到数据。
  • 再加 1 条数据,通常索引高度仍为 1 层。 因为一个 InnoDB 页(默认 16KB)可以存放很多行记录(具体取决于行大小)。两条数据远未达到页的容量上限。
  • 何时变成 2 层?根页(第 1 层)存满后,会发生页分裂。根页会升级为“索引目录页”,其原数据会被拆分到新的“叶子页”中。此时,树的高度变为 2。查询需要 2 次 I/O(根页 -> 叶子页)。
    • 关键阈值:取决于你的行记录大小。假设单行记录(含所有字段和行头开销)大小为 1KB,那么一个 16KB 的页大约能存 15 条。当插入第 16 条时,可能触发分裂,高度变为 2。
    • 影响因素innodb_page_size(页大小)、主键类型(如 UUID 比自增 INT 更占空间)、是否有大字段(TEXT/BLOB)等。

小结:索引高度增长是为了维持 B+ 树的平衡与查询效率。核心是 “页的填充与分裂”。设计时建议使用有序且紧凑的主键(如自增 BIGINT),以延缓页分裂和高度增长。


2. Redis 红锁是不是一定没问题?如果在多个节点下一定要稳定的分布式锁该怎么做?

  • 红锁(RedLock)不一定没问题,存在争议。 其初衷是在多个独立 Redis 主节点上部署,以容忍少数节点故障。但 Martin Kleppmann 等人指出其问题:
    1. 依赖系统时钟:若发生时钟跳跃,可能导致锁过期时间计算错误,破坏安全性。
    2. GC 停顿等延迟:在锁有效期内,若客户端发生长时间 GC 停顿,锁可能过期并被其他客户端获取,导致同时持有锁。
    3. 网络延迟:尽管要求多数节点获取成功,但在极端网络分区下,仍可能产生“脑裂”,出现多个客户端自认为持锁。
  • 如何实现更稳定的分布式锁?
    1. 首选基于共识算法的系统:对于强一致性要求极高的场景(如金融核心交易),避免使用 Redis。应使用 ZooKeeper、etcd 或 Consul。它们通过 Zab/Raft 协议保证强一致性,锁的本质是创建临时有序节点,安全性更高。
    2. 如果必须用 Redis
      • 单 Redis 实例 + 持久化:在非极端可用性要求下,一个配置了 AOF fsync=always 的主从 Redis 已足够可靠,且更简单。
      • Redisson 的实现:Java 生态中 Redisson 库的 RedLock 实现增加了看门狗(自动续期)等功能,提升了易用性,但并未解决上述理论争议。
    3. 架构设计上规避:尽可能避免使用分布式锁。可通过串行化资源(如账户号哈希到固定处理线程)、乐观锁(带版本号更新)、状态机等方式实现互斥。

结论:没有“绝对完美”的分布式锁。根据 CAP 权衡选择:要高可用和性能(可接受极端情况下的风险)可选 Redis 方案;要强一致性和安全,必须选基于共识算法的组件。


3. 转账业务中,网络波动导致分支事务悬挂时,如何设计补偿机制?

这是 Seata 等分布式事务框架中典型的“悬挂”问题

  • 场景:TM 全局事务回滚后,因网络波动,RM 才收到并执行了二阶段 try 请求,导致该分支资源被锁定,无法释放。
  • 补偿机制设计
    1. 框架内置防悬挂:Seata 已通过 事务控制记录 实现防悬挂。在 try 阶段前,会插入全局锁记录。如果先收到 rollback 并删除了记录,后续迟到的 try 会因检查不到记录而直接返回失败。
    2. 增强的补偿策略
      • 状态检查与清理任务:部署一个后台定时任务,扫描长时间处于 try 状态(如超时30秒)的分支事务。主动向其 TC 查询全局事务状态,若全局事务已回滚/完成,则触发本地分支的反向补偿操作(即执行一个抵消 try 操作的业务逻辑),并清理资源。
      • 业务幂等与日志:所有补偿操作(包括 cancel 和手动清理)必须幂等,基于 xidbranch_id 确保只执行一次。记录详细的操作日志和上下文,方便人工审计和介入。
      • 告警与人工介入:当悬挂事件发生并触发自动补偿后,应发出告警(如通知运维和开发),以便复查根本原因(网络配置、超时时间等)。

核心“防”重于“治”。通过合理设置超时时间、优化网络、利用框架机制防止大部分悬挂。再通过 “状态核对+定时清理” 作为最终安全网。


4. 在同城双活架构中,如何实现交易请求的无缝切换?当主中心故障时,如何保证事务最终一致性?

  • 请求无缝切换
    1. 全局负载均衡(GSLB)+ DNS/HTTP DNS:用户请求首先到达 GSLB,它基于健康检查、地理位置、负载情况,将用户智能解析到同城双活的某个中心入口。
    2. 应用层无状态:应用服务本身无状态,可在两个中心任意部署和扩缩容。用户会话(Session)存储于中心化的共享存储(如同城延迟极低的 Redis 集群)。
    3. 故障检测与引流:当 GSLB 或集群内部的负载均衡器(如 Nginx)检测到某个中心的应用集群整体不可用(如断电),可在秒级内将流量全部切至幸存中心。对用户而言,可能仅遭遇一次请求失败(快速重试后成功)或短暂卡顿。
  • 保证事务最终一致性(主中心故障时)
    1. 数据层双活是关键难点。通常采用 “单向同步+最终一致” 模式。
      • 设定一个中心(如 Center-A)为主写中心,负责核心事务写操作。
      • 数据库主库部署在 Center-A,在 Center-B 部署同城延迟极低的只读从库(利用 MySQL 半同步复制或并行复制)。
    2. 故障切换时
      • 写操作:立即将所有写流量切换到 Center-B。此时,需要将 Center-B 的从库提升为主库(需要配合 VIP 切换或代理层修改配置)。
      • 未完成事务:在 Center-A 故障瞬间,可能有一些已提交的事务未同步到 Center-B。这会产生少量数据不一致
    3. 最终一致性保障
      • 切换后核对与补偿:切换完成后,启动数据核对任务,对比切换时间点前后两个中心的数据差异(基于 binlog 或业务日志)。发现不一致时,触发补偿操作(以 Center-B 新主库的数据为基准进行修补)。
      • 业务容忍与冲正:对于转账类业务,可能出现“钱已扣,但对方未到账”的中间状态。需有全局事务状态跟踪冲正/补发机制,在核对后完成最终处理。
      • 优雅降级:切换期间,部分强一致性要求极高的业务(如实时扣款)可短暂降级(如提示“系统繁忙”),待数据核对完成后再开放。

核心思想流量切换要快,数据恢复要稳。接受 RPO(恢复点目标)> 0 的现实,通过事后核对与补偿达到最终一致。


5. 在反洗钱监控中,如何用 Drools 实现实时规则匹配?单日交易超百万笔时,如何优化规则引擎性能?

  • Drools 实现实时匹配
    1. 规则定义:将反洗钱规则(如“同一账号2小时内分散转入集中转出且总额超100万”)用 DRL 语言编写。规则作用于交易事件(Fact) 对象。
    2. 会话管理:为每个交易请求或每批交易创建一个 StatelessKieSession(无状态会话,适用于简单事件处理)或 StatefulKieSession(有状态会话,可用于关联多笔交易,但需注意内存和清理)。实时交易传入后,插入会话(insert),触发规则(fireAllRules)。
    3. 规则结果:规则触发后,会执行 RHS(结果侧)动作,如生成预警事件、更新交易风险等级等。
  • 百万笔/日性能优化
    1. 规则优化
      • 条件(LHS)优化:将最严格、最易失败的条件放在前面,利用 Drools 的 “节点共享” 特性。
      • 避免 from 子句和递归:它们会严重降低性能。
      • 规则拆分:将复杂规则拆分为多个简单规则,或根据业务维度(如交易类型)划分规则集,减少无关规则的评估。
    2. 架构优化
      • 预编译与缓存:规则文件(KJAR)在启动时预编译,并缓存 KieContainerKieSession,避免重复编译。
      • 并行匹配:利用 Drools 的 PHREAK 算法(默认)的并行能力。考虑使用 kie-baseevent-processing-modestream 模式处理事件流。
      • 异步与批处理:引入消息队列(如 Kafka)。交易数据先写入队列,规则引擎批量消费(如每100条或每100毫秒)进行处理,提升吞吐。
      • 热点规则分离:对于极其高频的核心规则(如“黑名单校验”),可剥离出规则引擎,用 Redis Bitmap 或布隆过滤器在网关层先行过滤。
      • 内存调优:合理设置 JVM 和 Drools 的堆内存,监控 GC。

总结:实时匹配依靠事件插入+规则触发。高性能之道在于 “规则精简 + 会话复用 + 异步批处理 + 热点剥离”


6. 在账户历史数据归档中,如何实现 TB 级数据迁移?当迁移中断时,如何保证数据的完整性?

  • TB级数据迁移方案
    1. 逻辑迁移(推荐):使用 pt-archiver 等专业工具。它通过小批量(如每次1000条)SELECTDELETE/INSERT,在事务内将数据从源表迁移到目标归档表。优点是可在线执行、可中断、可过滤数据、对业务影响相对小(通过控制执行速度)。
    2. 物理迁移:适用于停机窗口或新建从库场景。直接拷贝 ibd 数据文件或使用 Transportable Tablespaces(可传输表空间)功能。速度极快,但操作复杂,需要锁表或短暂的只读。
    3. 增量同步:无论采用哪种方式,在迁移期间,必须持续捕获并应用源表的增量变更(binlog 或触发器日志),直到归档完成并切换。
  • 保证迁移完整性(中断恢复)
    1. 断点续传:迁移工具(如 pt-archiver)或自研脚本必须记录断点(如最后归档成功的 max(id) 或时间戳)。重启后能从断点继续。
    2. 数据一致性校验:迁移完成后,使用 pt-table-checksum 进行全量校验。对于 TB 级数据,可在业务低峰期分批校验。
    3. 原子性切换:当确认数据一致后,通过 RENAME TABLE 命令(原子操作)将原表与归档表互换,或修改应用配置指向新表。此过程应极短,并配合应用短暂停机或只读。
    4. 备份与回滚计划:迁移开始前,务必备份原表。整个操作应有详细步骤和回滚方案(如 RENAME TABLE 换回)。

核心原则可中断、可核对、可回滚。对于在线业务,逻辑迁移+增量同步是更稳妥的选择。


7. 在网关中,如何设计防刷限流策略?当检测到恶意请求时,如何实现自动封禁?

  • 防刷限流策略(分层防御)
    1. 全局维度(Gateway Level):限制整个网关集群对特定后端服务的总 QPS/TPS,防止雪崩。可使用 令牌桶/漏桶算法
    2. 用户/客户端维度(User Level)
      • API 密钥速率限制:为每个 api_key 设置每秒/每天请求上限。
      • IP 速率限制:限制单个 IP 的请求频率。注意代理IP问题
    3. 业务维度(Business Level):针对核心业务接口(如登录、注册、下单)实施更严格的独立限流。
    4. 高级策略
      • 滑动窗口:更精确地控制单位时间内的请求数。
      • 并发控制:限制同一用户/IP 对同一资源的并发请求数。
      • 动态限流:根据系统负载(CPU、线程池)动态调整限流阈值。
  • 自动封禁机制
    1. 检测规则:定义恶意请求特征,如:单个 IP 在短时间(如1分钟)内登录失败超过50次、高频访问敏感接口、符合特定攻击模式(SQL注入特征)等。
    2. 计数与判定:使用 Redis 存储计数(INCR 命令,设置过期时间)。当计数超过阈值,则触发封禁。
    3. 封禁执行
      • 短期封禁:将恶意标识(IP、UserID)写入 Redis,并设置一个较短的 TTL(如10分钟)。网关在过滤器中查询此黑名单,直接拒绝请求。
      • 长期封禁:对于严重或持续攻击,可将标识写入数据库持久化黑名单,并同步到所有网关节点。
    4. 自动化与降级:整个流程可通过网关插件(如 Spring Cloud Gateway 自定义过滤器)或独立的风控服务自动完成。同时提供人工审核后台,支持封禁/解封操作。

架构示例Nginx/Spring Cloud Gateway + Redis(计数器与黑名单) + 风控规则引擎(可选,用于复杂规则)+ 管理后台


8. 如何用 K8s 实现有状态服务部署?当 pod 漂移时,如何保证数据不丢失?

  • 有状态服务部署

    1. 使用 StatefulSet:这是部署有状态应用(如 MySQL、Redis、ZooKeeper)的首选控制器。它提供:
      • 稳定的网络标识:Pod 名称(<statefulset-name>-<ordinal-index>)和对应的 Headless Service 域名稳定。
      • 稳定的持久化存储:通过 volumeClaimTemplates 为每个 Pod 动态创建独立的 PersistentVolumeClaim (PVC)PersistentVolume (PV)
      • 有序的部署/扩缩容:按序号顺序启停 Pod。
  • Pod 漂移时数据不丢失

    • 核心是:数据与 Pod 分离,存储卷生命周期独立于 Pod

    1. 持久卷(PV)的类型:使用网络持久化存储,如 云厂商的云盘(AWS EBS, GCP PD, Azure Disk)、NFS、Ceph RBD、Longhorn 等。当 Pod 在节点间漂移时,K8s 可以将相同的 PVC/PV 挂载到新节点的新 Pod 上
    2. StatefulSet 的工作流程
      • Pod-A(mysql-0)运行在 Node-1,挂载着 pvc-mysql-0(背后是云盘 Volume-A)。
      • 当 Node-1 宕机,Pod-A 变为 Terminating
      • StatefulSet 控制器会在其他健康节点(如 Node-2)上重建一个同名 Pod(mysql-0
      • 调度器确保 Node-2 能访问 Volume-A(云盘本身可跨可用区挂载,具体看类型和配置)。
      • 新 Pod mysql-0 成功挂载原有的 pvc-mysql-0/Volume-A,数据完好无损。
    3. 关键配置
      • storageClassName:指定正确的存储类。
      • volumeClaimTemplates:在 StatefulSet 中定义,确保每个 Pod 都有专属 PVC。
      • Pod 反亲和性:避免所有有状态 Pod 调度到同一节点,分散风险。

核心StatefulSet + 网络持久化存储 是 K8s 上保障有状态应用数据和身份稳定的黄金搭档。


9. 在新服务上线时,如何用 Istio 实现用户级灰度发布?当发现问题时,如何快速回滚并减少影响?

  • Istio 用户级灰度发布

    1. 部署策略:同时部署两个 Deploymentstable(当前稳定版,v1)和 canary(新版本,v2)。它们通过 K8s Service 标签选择器关联到同一个服务。

    2. 流量划分:创建 Istio VirtualServiceDestinationRule

      • DestinationRule:定义 stablecanary 两个 subset

      • VirtualService:配置基于 HTTP 标头的路由规则。例如:

        yaml

        http:
        - match:
          - headers:
              cookie:
                regex: "^(.*?;)?(user=test_user)(;.*)?$" # 匹配特定测试用户Cookie
          route:
          - destination:
              host: my-svc
              subset: canary # 测试用户流量去v2
        - route: # 其他所有用户
          - destination:
              host: my-svc
              subset: stable # 普通用户流量去v1
        

      可以根据 user-idx-user-group 等任何自定义标头进行精细控制。

    3. 渐进式灰度:可以调整规则,从 1% 的特定用户开始,逐步扩大用户范围或流量比例。

  • 快速回滚

    1. 回滚操作:发现问题后,立即修改或删除上述 VirtualService 中的灰度规则,将所有流量立刻切回 stable 版本。这几乎是秒级生效的。
    2. 减少影响
      • 监控与告警:在灰度期间,对 canary 版本进行关键指标监控(延迟、错误率、业务指标),并设置告警阈值。一旦触发,自动或手动触发回滚。
      • 会话保持:对于 Web 应用,确保同一用户的多次请求在灰度期间都发往同一版本,避免体验割裂。可在 VirtualService 中配置一致性哈希
      • 状态清理:如果新版本有数据 schema 变更等副作用,回滚后可能需要配套的数据回滚脚本。

优势:Istio 将流量治理与业务代码解耦,实现了无需重启应用、动态、精细的发布控制。


10. 在 AI 智能客服场景中,如何实现多轮对话的上下文管理?当用户问题模糊时,如何设计引导策略?

  • 多轮对话上下文管理
    1. 数据结构:维护一个 “对话会话(Dialog Session)” 对象,核心字段包括:session_id(唯一标识),user_id,以及一个 “对话轮次(Turn)列表”。每个 Turn 记录 user_queryai_responsetimestamp 以及从响应中提取的关键意图/实体/状态
    2. 存储与检索
      • 存储介质:使用内存数据库 Redis(存储活跃会话,TTL 可设为会话超时时间,如30分钟)持久化数据库(如 MySQL/MongoDB,用于长期存档和分析)。
      • 关键:每次用户新请求到来时,凭 session_id 从 Redis 中快速加载完整的上下文历史,作为大语言模型(LLM)或对话引擎的输入。
    3. 上下文窗口与摘要
      • 对于有上下文长度限制的 LLM(如早期 GPT 模型),不能传入全部历史。需要采用滑动窗口(仅保留最近 N 轮)或历史摘要技术。即,将过往长对话压缩提炼成一段简短的背景摘要,与最近几轮详细对话一起送入模型。
  • 模糊问题引导策略
    1. 意图识别与槽位填充:当用户说“我要改签”,NLU 模块应识别出“改签”意图,但发现缺失必要“槽位”(slots),如车次/航班号、日期
    2. 主动澄清式提问
      • 设计预设的澄清话术模板,如“请问您要改签哪一天的航班呢?”、“您能提供一下订单号吗?”。
      • 策略:根据缺失槽位的重要性获取难度,决定是一次性全部询问还是逐一轮询。
    3. 提供选项(降低用户负担):如果可能,从上下文中提取候选值供用户选择。例如:“您是想查询 余额(选项1)交易记录(选项2) 还是办理转账(选项3) 呢?”。
    4. 利用多模态信息:在 App 或网页中,可结合界面上下文(用户当前正在查看的页面、点击的按钮)来消歧。

系统设计:一个典型的架构是:对话管理(DM)模块维护状态和上下文;NLU 模块解析用户输入;策略模块根据状态和 NLU 结果决定是调用业务API查询知识库还是执行引导;最后通过 NLG 生成友好回复。

一、力扣 135 题:分发糖果

解题思路

这是一个典型的贪心算法问题,需要两次遍历数组以满足两个条件:

  1. 从左向右遍历:确保右边评分更高的孩子比左边孩子多
  2. 从右向左遍历:确保左边评分更高的孩子比右边孩子多

Python 代码实现

python

def candy(ratings):
    """
    时间复杂度: O(n)
    空间复杂度: O(n)
    """
    n = len(ratings)
    if n == 0:
        return 0
    
    # 初始化每个孩子至少1个糖果
    candies = [1] * n
    
    # 从左向右遍历:右边评分高的情况
    for i in range(1, n):
        if ratings[i] > ratings[i-1]:
            candies[i] = candies[i-1] + 1
    
    # 从右向左遍历:左边评分高的情况
    for i in range(n-2, -1, -1):
        if ratings[i] > ratings[i+1]:
            candies[i] = max(candies[i], candies[i+1] + 1)
    
    return sum(candies)

# 测试用例
print(candy([1, 0, 2]))  # 输出: 5
print(candy([1, 2, 2]))  # 输出: 4
print(candy([1, 3, 2, 2, 1]))  # 输出: 7

代码解释

  1. 第一次遍历:从左到右,如果当前孩子评分比左边高,则糖果数 = 左边孩子糖果数 + 1
  2. 第二次遍历:从右到左,如果当前孩子评分比右边高,则取 max(当前糖果数, 右边孩子糖果数 + 1)
  3. 两次遍历确保了两个方向的约束都得到满足

空间优化版本(O(1)空间)

python

def candy_optimized(ratings):
    n = len(ratings)
    if n <= 1:
        return n
    
    total = 1  # 第一个孩子
    up = 1     # 上升序列长度
    down = 0   # 下降序列长度
    peak = 1   # 峰值
    
    for i in range(1, n):
        if ratings[i] > ratings[i-1]:
            up += 1
            down = 0
            peak = up
            total += up
        elif ratings[i] == ratings[i-1]:
            up = 1
            down = 0
            peak = 1
            total += 1
        else:
            down += 1
            up = 1
            total += down
            # 如果下降序列长度超过了峰值,需要给峰顶孩子额外糖果
            if down >= peak:
                total += 1
    
    return total

二、SQL:统计"连续8小时累计的订单完成量"

问题场景

假设有一个订单表 orders,需要计算每个订单时间点往前推8小时内的累计完成量。

表结构示例

CREATE TABLE orders (
    order_id INT PRIMARY KEY,
    finish_time DATETIME,    -- 订单完成时间
    quantity INT             -- 订单完成数量
);

-- 示例数据
INSERT INTO orders VALUES
(1, '2024-01-01 08:00:00', 10),
(2, '2024-01-01 09:30:00', 5),
(3, '2024-01-01 10:45:00', 8),
(4, '2024-01-01 11:20:00', 12),
(5, '2024-01-01 16:00:00', 15);

解决方案(窗口函数)

-- 方法1:使用窗口函数的RANGE子句(MySQL 8.0+, PostgreSQL, SQL Server 2012+)
SELECT 
    order_id,
    finish_time,
    quantity,
    SUM(quantity) OVER (
        ORDER BY UNIX_TIMESTAMP(finish_time)  -- 按时间戳排序
        RANGE BETWEEN 8 * 3600 PRECEDING AND CURRENT ROW
    ) AS cumulative_quantity_8h
FROM orders
ORDER BY finish_time;

-- 方法2:如果数据库支持时间间隔(如PostgreSQL)
SELECT 
    order_id,
    finish_time,
    quantity,
    SUM(quantity) OVER (
        ORDER BY finish_time 
        RANGE BETWEEN INTERVAL '8 HOUR' PRECEDING AND CURRENT ROW
    ) AS cumulative_quantity_8h
FROM orders
ORDER BY finish_time;

-- 方法3:使用自连接(通用但性能较差,适用于所有数据库)
SELECT 
    o1.order_id,
    o1.finish_time,
    o1.quantity,
    SUM(o2.quantity) AS cumulative_quantity_8h
FROM orders o1
LEFT JOIN orders o2 
    ON o2.finish_time <= o1.finish_time 
    AND o2.finish_time >= DATE_SUB(o1.finish_time, INTERVAL 8 HOUR)
GROUP BY o1.order_id, o1.finish_time, o1.quantity
ORDER BY o1.finish_time;

-- 方法4:生成时间序列后统计(适用于需要按小时粒度展示)
WITH hourly_buckets AS (
    -- 生成每小时的时间桶
    SELECT 
        DATE_FORMAT(finish_time, '%Y-%m-%d %H:00:00') AS hour_bucket,
        SUM(quantity) AS hourly_quantity
    FROM orders
    GROUP BY DATE_FORMAT(finish_time, '%Y-%m-%d %H:00:00')
)
SELECT 
    hour_bucket,
    hourly_quantity,
    SUM(hourly_quantity) OVER (
        ORDER BY hour_bucket
        ROWS BETWEEN 7 PRECEDING AND CURRENT ROW
    ) AS cumulative_8h_quantity
FROM hourly_buckets
ORDER BY hour_bucket;

关键点说明

  1. RANGE vs ROWS
    • RANGE:基于值的范围(适合时间范围统计)
    • ROWS:基于行数范围(适合固定行数统计)
  2. 性能优化建议
    • finish_time 上创建索引
    • 对于海量数据,考虑预先聚合到小时/分钟粒度
    • 使用分区表按时间分区
  3. 业务扩展
    • 可以添加 PARTITION BY 子句按业务维度分组统计
    • 可以计算滑动平均、同比环比等指标

三、多线程:3个窗口同时卖100张票

Java实现(使用synchronized)

java

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;

public class TicketSalesSystem {
    
    // 方法1:使用synchronized关键字(基础版)
    static class TicketWindowBasic implements Runnable {
        private static int totalTickets = 100;
        private static final Object lock = new Object();
        private int windowId;
        private long startTime;
        private long endTime;
        private int soldCount = 0;
        
        public TicketWindowBasic(int windowId) {
            this.windowId = windowId;
        }
        
        @Override
        public void run() {
            startTime = System.currentTimeMillis();
            
            while (true) {
                synchronized (lock) {
                    if (totalTickets <= 0) {
                        break;
                    }
                    // 模拟卖票时间
                    try {
                        Thread.sleep((int)(Math.random() * 50));
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    
                    System.out.printf("窗口%d卖出第%d张票%n", windowId, totalTickets);
                    totalTickets--;
                    soldCount++;
                }
            }
            
            endTime = System.currentTimeMillis();
            System.out.printf("窗口%d共卖出%d张票,耗时%d毫秒%n", 
                windowId, soldCount, endTime - startTime);
        }
    }
    
    // 方法2:使用ReentrantLock(更灵活)
    static class TicketWindowWithLock implements Runnable {
        private static AtomicInteger totalTickets = new AtomicInteger(100);
        private static ReentrantLock lock = new ReentrantLock();
        private int windowId;
        private long startTime;
        private int soldCount = 0;
        
        public TicketWindowWithLock(int windowId) {
            this.windowId = windowId;
        }
        
        @Override
        public void run() {
            startTime = System.currentTimeMillis();
            
            while (true) {
                lock.lock();
                try {
                    if (totalTickets.get() <= 0) {
                        break;
                    }
                    
                    // 模拟卖票时间
                    Thread.sleep((int)(Math.random() * 50));
                    
                    int currentTicket = totalTickets.getAndDecrement();
                    System.out.printf("窗口%d卖出第%d张票%n", windowId, currentTicket);
                    soldCount++;
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    lock.unlock();
                }
            }
            
            long endTime = System.currentTimeMillis();
            System.out.printf("窗口%d共卖出%d张票,耗时%d毫秒%n", 
                windowId, soldCount, endTime - startTime);
        }
    }
    
    // 方法3:使用CountDownLatch确保同时开始
    static class TicketWindowConcurrent implements Runnable {
        private static AtomicInteger totalTickets = new AtomicInteger(100);
        private CountDownLatch startLatch;
        private int windowId;
        private int soldCount = 0;
        private long startTime;
        private long endTime;
        
        public TicketWindowConcurrent(int windowId, CountDownLatch startLatch) {
            this.windowId = windowId;
            this.startLatch = startLatch;
        }
        
        @Override
        public void run() {
            try {
                startLatch.await(); // 等待所有线程就绪
                startTime = System.currentTimeMillis();
                
                while (true) {
                    int current = totalTickets.get();
                    if (current <= 0) {
                        break;
                    }
                    
                    // 使用CAS操作,避免超卖
                    if (totalTickets.compareAndSet(current, current - 1)) {
                        // 卖票成功
                        soldCount++;
                        System.out.printf("窗口%d卖出第%d张票%n", windowId, current);
                        
                        // 模拟卖票耗时
                        Thread.sleep((int)(Math.random() * 50));
                    }
                }
                
                endTime = System.currentTimeMillis();
                System.out.printf("窗口%d共卖出%d张票,耗时%d毫秒%n", 
                    windowId, soldCount, endTime - startTime);
                    
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    
    public static void main(String[] args) throws InterruptedException {
        System.out.println("=== 方法1:基础synchronized实现 ===");
        Thread[] basicThreads = new Thread[3];
        TicketWindowBasic.totalTickets = 100; // 重置票数
        
        for (int i = 0; i < 3; i++) {
            basicThreads[i] = new Thread(new TicketWindowBasic(i + 1));
            basicThreads[i].start();
        }
        
        for (Thread t : basicThreads) {
            t.join();
        }
        
        System.out.println("\n=== 方法2:ReentrantLock实现 ===");
        Thread[] lockThreads = new Thread[3];
        TicketWindowWithLock.totalTickets.set(100); // 重置票数
        
        for (int i = 0; i < 3; i++) {
            lockThreads[i] = new Thread(new TicketWindowWithLock(i + 1));
            lockThreads[i].start();
        }
        
        for (Thread t : lockThreads) {
            t.join();
        }
        
        System.out.println("\n=== 方法3:CAS无锁实现(推荐) ===");
        CountDownLatch startLatch = new CountDownLatch(1);
        Thread[] concurrentThreads = new Thread[3];
        TicketWindowConcurrent.totalTickets.set(100); // 重置票数
        
        for (int i = 0; i < 3; i++) {
            concurrentThreads[i] = new Thread(new TicketWindowConcurrent(i + 1, startLatch));
            concurrentThreads[i].start();
        }
        
        // 确保所有线程都准备好后同时开始
        Thread.sleep(100);
        startLatch.countDown();
        
        for (Thread t : concurrentThreads) {
            t.join();
        }
    }
}

关键要点总结

  1. 线程安全保证
    • 使用同步机制(synchronized/Lock)确保票数操作的原子性
    • CAS操作可以避免锁竞争,提升并发性能
  2. 避免超卖的核心
    • 检查票数和减少票数必须是原子操作
    • 使用 compareAndSet 或锁机制保护共享变量
  3. 性能考虑
    • 锁的粒度要尽量小,只在必要代码段加锁
    • 可以考虑使用 ThreadLocal 缓存部分数据
    • 使用无锁算法(如Atomic类)减少线程阻塞
  4. 统计准确性
    • 使用 CountDownLatchCyclicBarrier 确保同时开始
    • 精确记录每个线程的开始和结束时间
    • 避免时间统计被同步操作影响

这三种实现方式各有优劣,可以根据具体场景选择:

  • 简单场景:使用 synchronized
  • 需要更细粒度控制:使用 ReentrantLock
  • 高并发场景:使用 CAS 无锁算法