很厉害的面试题
面试题
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 等人指出其问题:
- 依赖系统时钟:若发生时钟跳跃,可能导致锁过期时间计算错误,破坏安全性。
- GC 停顿等延迟:在锁有效期内,若客户端发生长时间 GC 停顿,锁可能过期并被其他客户端获取,导致同时持有锁。
- 网络延迟:尽管要求多数节点获取成功,但在极端网络分区下,仍可能产生“脑裂”,出现多个客户端自认为持锁。
- 如何实现更稳定的分布式锁?
- 首选基于共识算法的系统:对于强一致性要求极高的场景(如金融核心交易),避免使用 Redis。应使用 ZooKeeper、etcd 或 Consul。它们通过 Zab/Raft 协议保证强一致性,锁的本质是创建临时有序节点,安全性更高。
- 如果必须用 Redis:
- 单 Redis 实例 + 持久化:在非极端可用性要求下,一个配置了 AOF
fsync=always的主从 Redis 已足够可靠,且更简单。 - Redisson 的实现:Java 生态中 Redisson 库的 RedLock 实现增加了看门狗(自动续期)等功能,提升了易用性,但并未解决上述理论争议。
- 单 Redis 实例 + 持久化:在非极端可用性要求下,一个配置了 AOF
- 架构设计上规避:尽可能避免使用分布式锁。可通过串行化资源(如账户号哈希到固定处理线程)、乐观锁(带版本号更新)、状态机等方式实现互斥。
结论:没有“绝对完美”的分布式锁。根据 CAP 权衡选择:要高可用和性能(可接受极端情况下的风险)可选 Redis 方案;要强一致性和安全,必须选基于共识算法的组件。
3. 转账业务中,网络波动导致分支事务悬挂时,如何设计补偿机制?
这是 Seata 等分布式事务框架中典型的“悬挂”问题。
- 场景:TM 全局事务回滚后,因网络波动,RM 才收到并执行了二阶段
try请求,导致该分支资源被锁定,无法释放。 - 补偿机制设计:
- 框架内置防悬挂:Seata 已通过
事务控制记录实现防悬挂。在try阶段前,会插入全局锁记录。如果先收到rollback并删除了记录,后续迟到的try会因检查不到记录而直接返回失败。 - 增强的补偿策略:
- 状态检查与清理任务:部署一个后台定时任务,扫描长时间处于
try状态(如超时30秒)的分支事务。主动向其 TC 查询全局事务状态,若全局事务已回滚/完成,则触发本地分支的反向补偿操作(即执行一个抵消try操作的业务逻辑),并清理资源。 - 业务幂等与日志:所有补偿操作(包括
cancel和手动清理)必须幂等,基于xid和branch_id确保只执行一次。记录详细的操作日志和上下文,方便人工审计和介入。 - 告警与人工介入:当悬挂事件发生并触发自动补偿后,应发出告警(如通知运维和开发),以便复查根本原因(网络配置、超时时间等)。
- 状态检查与清理任务:部署一个后台定时任务,扫描长时间处于
- 框架内置防悬挂:Seata 已通过
核心:“防”重于“治”。通过合理设置超时时间、优化网络、利用框架机制防止大部分悬挂。再通过 “状态核对+定时清理” 作为最终安全网。
4. 在同城双活架构中,如何实现交易请求的无缝切换?当主中心故障时,如何保证事务最终一致性?
- 请求无缝切换:
- 全局负载均衡(GSLB)+ DNS/HTTP DNS:用户请求首先到达 GSLB,它基于健康检查、地理位置、负载情况,将用户智能解析到同城双活的某个中心入口。
- 应用层无状态:应用服务本身无状态,可在两个中心任意部署和扩缩容。用户会话(Session)存储于中心化的共享存储(如同城延迟极低的 Redis 集群)。
- 故障检测与引流:当 GSLB 或集群内部的负载均衡器(如 Nginx)检测到某个中心的应用集群整体不可用(如断电),可在秒级内将流量全部切至幸存中心。对用户而言,可能仅遭遇一次请求失败(快速重试后成功)或短暂卡顿。
- 保证事务最终一致性(主中心故障时):
- 数据层双活是关键难点。通常采用 “单向同步+最终一致” 模式。
- 设定一个中心(如 Center-A)为主写中心,负责核心事务写操作。
- 数据库主库部署在 Center-A,在 Center-B 部署同城延迟极低的只读从库(利用 MySQL 半同步复制或并行复制)。
- 故障切换时:
- 写操作:立即将所有写流量切换到 Center-B。此时,需要将 Center-B 的从库提升为主库(需要配合 VIP 切换或代理层修改配置)。
- 未完成事务:在 Center-A 故障瞬间,可能有一些已提交的事务未同步到 Center-B。这会产生少量数据不一致。
- 最终一致性保障:
- 切换后核对与补偿:切换完成后,启动数据核对任务,对比切换时间点前后两个中心的数据差异(基于 binlog 或业务日志)。发现不一致时,触发补偿操作(以 Center-B 新主库的数据为基准进行修补)。
- 业务容忍与冲正:对于转账类业务,可能出现“钱已扣,但对方未到账”的中间状态。需有全局事务状态跟踪和冲正/补发机制,在核对后完成最终处理。
- 优雅降级:切换期间,部分强一致性要求极高的业务(如实时扣款)可短暂降级(如提示“系统繁忙”),待数据核对完成后再开放。
- 数据层双活是关键难点。通常采用 “单向同步+最终一致” 模式。
核心思想:流量切换要快,数据恢复要稳。接受 RPO(恢复点目标)> 0 的现实,通过事后核对与补偿达到最终一致。
5. 在反洗钱监控中,如何用 Drools 实现实时规则匹配?单日交易超百万笔时,如何优化规则引擎性能?
- Drools 实现实时匹配:
- 规则定义:将反洗钱规则(如“同一账号2小时内分散转入集中转出且总额超100万”)用 DRL 语言编写。规则作用于交易事件(Fact) 对象。
- 会话管理:为每个交易请求或每批交易创建一个
StatelessKieSession(无状态会话,适用于简单事件处理)或StatefulKieSession(有状态会话,可用于关联多笔交易,但需注意内存和清理)。实时交易传入后,插入会话(insert),触发规则(fireAllRules)。 - 规则结果:规则触发后,会执行 RHS(结果侧)动作,如生成预警事件、更新交易风险等级等。
- 百万笔/日性能优化:
- 规则优化:
- 条件(LHS)优化:将最严格、最易失败的条件放在前面,利用 Drools 的 “节点共享” 特性。
- 避免
from子句和递归:它们会严重降低性能。 - 规则拆分:将复杂规则拆分为多个简单规则,或根据业务维度(如交易类型)划分规则集,减少无关规则的评估。
- 架构优化:
- 预编译与缓存:规则文件(KJAR)在启动时预编译,并缓存
KieContainer和KieSession,避免重复编译。 - 并行匹配:利用 Drools 的
PHREAK算法(默认)的并行能力。考虑使用kie-base的event-processing-mode为stream模式处理事件流。 - 异步与批处理:引入消息队列(如 Kafka)。交易数据先写入队列,规则引擎批量消费(如每100条或每100毫秒)进行处理,提升吞吐。
- 热点规则分离:对于极其高频的核心规则(如“黑名单校验”),可剥离出规则引擎,用 Redis Bitmap 或布隆过滤器在网关层先行过滤。
- 内存调优:合理设置 JVM 和 Drools 的堆内存,监控 GC。
- 预编译与缓存:规则文件(KJAR)在启动时预编译,并缓存
- 规则优化:
总结:实时匹配依靠事件插入+规则触发。高性能之道在于 “规则精简 + 会话复用 + 异步批处理 + 热点剥离”。
6. 在账户历史数据归档中,如何实现 TB 级数据迁移?当迁移中断时,如何保证数据的完整性?
- TB级数据迁移方案:
- 逻辑迁移(推荐):使用
pt-archiver等专业工具。它通过小批量(如每次1000条)SELECT和DELETE/INSERT,在事务内将数据从源表迁移到目标归档表。优点是可在线执行、可中断、可过滤数据、对业务影响相对小(通过控制执行速度)。 - 物理迁移:适用于停机窗口或新建从库场景。直接拷贝
ibd数据文件或使用Transportable Tablespaces(可传输表空间)功能。速度极快,但操作复杂,需要锁表或短暂的只读。 - 增量同步:无论采用哪种方式,在迁移期间,必须持续捕获并应用源表的增量变更(binlog 或触发器日志),直到归档完成并切换。
- 逻辑迁移(推荐):使用
- 保证迁移完整性(中断恢复):
- 断点续传:迁移工具(如
pt-archiver)或自研脚本必须记录断点(如最后归档成功的max(id)或时间戳)。重启后能从断点继续。 - 数据一致性校验:迁移完成后,使用
pt-table-checksum进行全量校验。对于 TB 级数据,可在业务低峰期分批校验。 - 原子性切换:当确认数据一致后,通过
RENAME TABLE命令(原子操作)将原表与归档表互换,或修改应用配置指向新表。此过程应极短,并配合应用短暂停机或只读。 - 备份与回滚计划:迁移开始前,务必备份原表。整个操作应有详细步骤和回滚方案(如
RENAME TABLE换回)。
- 断点续传:迁移工具(如
核心原则:可中断、可核对、可回滚。对于在线业务,逻辑迁移+增量同步是更稳妥的选择。
7. 在网关中,如何设计防刷限流策略?当检测到恶意请求时,如何实现自动封禁?
- 防刷限流策略(分层防御):
- 全局维度(Gateway Level):限制整个网关集群对特定后端服务的总 QPS/TPS,防止雪崩。可使用 令牌桶/漏桶算法。
- 用户/客户端维度(User Level):
- API 密钥速率限制:为每个
api_key设置每秒/每天请求上限。 - IP 速率限制:限制单个 IP 的请求频率。注意代理IP问题。
- API 密钥速率限制:为每个
- 业务维度(Business Level):针对核心业务接口(如登录、注册、下单)实施更严格的独立限流。
- 高级策略:
- 滑动窗口:更精确地控制单位时间内的请求数。
- 并发控制:限制同一用户/IP 对同一资源的并发请求数。
- 动态限流:根据系统负载(CPU、线程池)动态调整限流阈值。
- 自动封禁机制:
- 检测规则:定义恶意请求特征,如:单个 IP 在短时间(如1分钟)内登录失败超过50次、高频访问敏感接口、符合特定攻击模式(SQL注入特征)等。
- 计数与判定:使用 Redis 存储计数(
INCR命令,设置过期时间)。当计数超过阈值,则触发封禁。 - 封禁执行:
- 短期封禁:将恶意标识(IP、UserID)写入 Redis,并设置一个较短的 TTL(如10分钟)。网关在过滤器中查询此黑名单,直接拒绝请求。
- 长期封禁:对于严重或持续攻击,可将标识写入数据库持久化黑名单,并同步到所有网关节点。
- 自动化与降级:整个流程可通过网关插件(如 Spring Cloud Gateway 自定义过滤器)或独立的风控服务自动完成。同时提供人工审核后台,支持封禁/解封操作。
架构示例:Nginx/Spring Cloud Gateway + Redis(计数器与黑名单) + 风控规则引擎(可选,用于复杂规则)+ 管理后台。
8. 如何用 K8s 实现有状态服务部署?当 pod 漂移时,如何保证数据不丢失?
-
有状态服务部署:
- 使用 StatefulSet:这是部署有状态应用(如 MySQL、Redis、ZooKeeper)的首选控制器。它提供:
- 稳定的网络标识:Pod 名称(
<statefulset-name>-<ordinal-index>)和对应的 Headless Service 域名稳定。 - 稳定的持久化存储:通过
volumeClaimTemplates为每个 Pod 动态创建独立的 PersistentVolumeClaim (PVC) 和 PersistentVolume (PV)。 - 有序的部署/扩缩容:按序号顺序启停 Pod。
- 稳定的网络标识:Pod 名称(
- 使用 StatefulSet:这是部署有状态应用(如 MySQL、Redis、ZooKeeper)的首选控制器。它提供:
-
Pod 漂移时数据不丢失:
-
核心是:数据与 Pod 分离,存储卷生命周期独立于 Pod。
- 持久卷(PV)的类型:使用网络持久化存储,如 云厂商的云盘(AWS EBS, GCP PD, Azure Disk)、NFS、Ceph RBD、Longhorn 等。当 Pod 在节点间漂移时,K8s 可以将相同的 PVC/PV 挂载到新节点的新 Pod 上。
- 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,数据完好无损。
- Pod-A(
- 关键配置:
storageClassName:指定正确的存储类。volumeClaimTemplates:在 StatefulSet 中定义,确保每个 Pod 都有专属 PVC。- Pod 反亲和性:避免所有有状态 Pod 调度到同一节点,分散风险。
-
核心:StatefulSet + 网络持久化存储 是 K8s 上保障有状态应用数据和身份稳定的黄金搭档。
9. 在新服务上线时,如何用 Istio 实现用户级灰度发布?当发现问题时,如何快速回滚并减少影响?
-
Istio 用户级灰度发布:
-
部署策略:同时部署两个
Deployment:stable(当前稳定版,v1)和canary(新版本,v2)。它们通过 K8s Service 标签选择器关联到同一个服务。 -
流量划分:创建 Istio VirtualService 和 DestinationRule。
-
DestinationRule:定义stable和canary两个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-id、x-user-group等任何自定义标头进行精细控制。 -
-
渐进式灰度:可以调整规则,从 1% 的特定用户开始,逐步扩大用户范围或流量比例。
-
-
快速回滚:
- 回滚操作:发现问题后,立即修改或删除上述
VirtualService中的灰度规则,将所有流量立刻切回stable版本。这几乎是秒级生效的。 - 减少影响:
- 监控与告警:在灰度期间,对
canary版本进行关键指标监控(延迟、错误率、业务指标),并设置告警阈值。一旦触发,自动或手动触发回滚。 - 会话保持:对于 Web 应用,确保同一用户的多次请求在灰度期间都发往同一版本,避免体验割裂。可在
VirtualService中配置一致性哈希。 - 状态清理:如果新版本有数据 schema 变更等副作用,回滚后可能需要配套的数据回滚脚本。
- 监控与告警:在灰度期间,对
- 回滚操作:发现问题后,立即修改或删除上述
优势:Istio 将流量治理与业务代码解耦,实现了无需重启应用、动态、精细的发布控制。
10. 在 AI 智能客服场景中,如何实现多轮对话的上下文管理?当用户问题模糊时,如何设计引导策略?
- 多轮对话上下文管理:
- 数据结构:维护一个 “对话会话(Dialog Session)” 对象,核心字段包括:
session_id(唯一标识),user_id,以及一个 “对话轮次(Turn)列表”。每个 Turn 记录user_query、ai_response、timestamp以及从响应中提取的关键意图/实体/状态。 - 存储与检索:
- 存储介质:使用内存数据库 Redis(存储活跃会话,TTL 可设为会话超时时间,如30分钟)持久化数据库(如 MySQL/MongoDB,用于长期存档和分析)。
- 关键:每次用户新请求到来时,凭
session_id从 Redis 中快速加载完整的上下文历史,作为大语言模型(LLM)或对话引擎的输入。
- 上下文窗口与摘要:
- 对于有上下文长度限制的 LLM(如早期 GPT 模型),不能传入全部历史。需要采用滑动窗口(仅保留最近 N 轮)或历史摘要技术。即,将过往长对话压缩提炼成一段简短的背景摘要,与最近几轮详细对话一起送入模型。
- 数据结构:维护一个 “对话会话(Dialog Session)” 对象,核心字段包括:
- 模糊问题引导策略:
- 意图识别与槽位填充:当用户说“我要改签”,NLU 模块应识别出“改签”意图,但发现缺失必要“槽位”(slots),如车次/航班号、日期。
- 主动澄清式提问:
- 设计预设的澄清话术模板,如“请问您要改签哪一天的航班呢?”、“您能提供一下订单号吗?”。
- 策略:根据缺失槽位的重要性和获取难度,决定是一次性全部询问还是逐一轮询。
- 提供选项(降低用户负担):如果可能,从上下文中提取候选值供用户选择。例如:“您是想查询 余额(选项1)、交易记录(选项2) 还是办理转账(选项3) 呢?”。
- 利用多模态信息:在 App 或网页中,可结合界面上下文(用户当前正在查看的页面、点击的按钮)来消歧。
系统设计:一个典型的架构是:对话管理(DM)模块维护状态和上下文;NLU 模块解析用户输入;策略模块根据状态和 NLU 结果决定是调用业务API、查询知识库还是执行引导;最后通过 NLG 生成友好回复。
一、力扣 135 题:分发糖果
解题思路
这是一个典型的贪心算法问题,需要两次遍历数组以满足两个条件:
- 从左向右遍历:确保右边评分更高的孩子比左边孩子多
- 从右向左遍历:确保左边评分更高的孩子比右边孩子多
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
- 第二次遍历:从右到左,如果当前孩子评分比右边高,则取
max(当前糖果数, 右边孩子糖果数 + 1) - 两次遍历确保了两个方向的约束都得到满足
空间优化版本(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;
关键点说明
- RANGE vs ROWS:
RANGE:基于值的范围(适合时间范围统计)ROWS:基于行数范围(适合固定行数统计)
- 性能优化建议:
- 在
finish_time上创建索引 - 对于海量数据,考虑预先聚合到小时/分钟粒度
- 使用分区表按时间分区
- 在
- 业务扩展:
- 可以添加
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();
}
}
}
关键要点总结
- 线程安全保证:
- 使用同步机制(synchronized/Lock)确保票数操作的原子性
- CAS操作可以避免锁竞争,提升并发性能
- 避免超卖的核心:
- 检查票数和减少票数必须是原子操作
- 使用
compareAndSet或锁机制保护共享变量
- 性能考虑:
- 锁的粒度要尽量小,只在必要代码段加锁
- 可以考虑使用
ThreadLocal缓存部分数据 - 使用无锁算法(如Atomic类)减少线程阻塞
- 统计准确性:
- 使用
CountDownLatch或CyclicBarrier确保同时开始 - 精确记录每个线程的开始和结束时间
- 避免时间统计被同步操作影响
- 使用
这三种实现方式各有优劣,可以根据具体场景选择:
- 简单场景:使用
synchronized - 需要更细粒度控制:使用
ReentrantLock - 高并发场景:使用 CAS 无锁算法