天然气销售优秀的系统模拟面试
项目背景:

Spring IoC(控制反转)通过将对象的创建和依赖关系的控制权从代码内部转移到外部容器,来解决组件间的耦合问题。它让组件不再主动寻找或创建自己的依赖,而是被动等待容器将其所需的依赖“注入”进来,从而实现面向接口的编程,达成松耦合。
Controller 与 Service 的解耦:
@Controller
public class GasPriceController {
@Autowired // 由IoC容器注入,Controller不关心具体实现
private GasPriceService gasPriceService;
}
GasPriceController只依赖于 GasPriceService这个接口。无论其业务逻辑如何变化,只要接口不变,控制器就无需修改。
Service 与 Repository/Mapper 的解耦
@Service
public class GasPriceServiceImpl implements GasPriceService {
@Autowired // 由IoC容器注入,Service不关心数据访问细节
private GasPriceMapper gasPriceMapper;
}
GasPriceServiceImpl只依赖于 GasPriceMapper接口。今天我们可以使用MyBatis实现这个Mapper,明天想换成JPA,只需定义新的Repository实现并配置为Bean即可,业务层的核心代码一行都不需要改动。

理想回答:
在这个项目中,我主要负责了客户管理和费用自动计算这两个核心模块。其中,气体价格管理是费用计算的核心
气体价格管理模块的设计:首先,我设计了一张gas_price表,用来存储阶梯气价规则,比如threshold_min(阶梯下限)、threshold_max(阶梯上限)、unit_price(单价)等字段。在费用计算的业务逻辑中,当收到一个用户的用气量后,我的GasBillingService会:
步骤一: 根据用户类型(如居民、商用)查询出对应的所有阶梯价格列表。
步骤二: 执行阶梯计算算法。这不是简单的乘法,而是需要用气量分段计算。步骤三: 将计算明细和总费用持久化到账单表中。
利用Spring的IoC和DI: 我将业务层(Service)和数据层(Mapper)都交给Spring容器管理。比如,在BillingController里用@Autowired注入GasBillingService,这样做使得各层职责清晰,耦合度低,极大地便于单元测试,我可以在测试时轻松注入一个Mock的Service。”
“利用Spring的声明式事务(@Transactional): 费用生成可能涉及多次数据库操作(查询用户、查询气价、插入账单、更新用户余额等)。我在Service方法上添加了@Transactional注解,确保这些操作在一个事务内,要么全部成功,要么全部回滚,完美解决了可能出现的账务不一致的复杂问题。”
“利用MyBatis的动态SQL: 在客户管理模块中,查询条件很复杂(可按姓名、地址、用户类型等多条件组合查询)。我使用了MyBatis的、等标签来动态生成SQL,避免了拼接SQL字符串的麻烦和风险,优雅地解决了复杂查询的问题。”

数据库表结构:可以设计一张名为 gas_price的表,包含以下字段:
price_id(价格ID):主键,唯一标识每条价格记录。
class_code(类码):表示气体类型或用户类别(如居民用气、商业用气),用于区分不同价格策略。
price(价格):存储具体的单价(例如,元/立方米)。
publish_time(发布时间):记录价格信息的发布时间戳。
effective_time(生效时间):价格开始生效的时间戳,确保系统能按时间切换价格。
status(状态):标识价格是否生效,例如用“ACTIVE”(生效)或“INACTIVE”(失效)表示。
存储方式:价格以多条记录的形式存储,每条记录代表一个特定类码在某个时间段的定价。例如,同一类码可能有多个历史价格记录,但只有一条状态为“ACTIVE”的当前有效价格。这种设计支持价格的历史追踪和灵活调整。
价格调整的流程实现
价格调整是一个关键业务操作,需要确保数据一致性和原子性。流程如下:
上传新价格数据:管理员通过系统界面输入新价格信息(包括类码、价格、生效时间等),后端接收数据后,先将其作为一条新记录插入gas_price表,初始状态设为“INACTIVE”。
执行价格切换:通过声明式事务(Declarative Transaction)确保原子性。在Java中,可以使用Spring框架的@Transactional注解来管理事务。具体步骤:
在Service层的方法上添加@Transactional,标识该方法为一个原子操作。
在方法内部,先查询当前生效的价格记录(状态为“ACTIVE”),然后将其状态更新为“INACTIVE”。
紧接着,将新插入的价格记录状态更新为“ACTIVE”。
如果任何一步操作失败(如数据库异常),事务会自动回滚,确保旧价格不会失效而新价格未生效,避免数据不一致。

1. 数据库设计层面
我会在数据库中设计两张核心表:
用户表 (user):其中包含一个 user_type字段(如:'RESIDENTIAL'表示居民,'INDUSTRIAL'表示工业)。
价格策略表 (billing_policy):包含字段如 user_type(关联用户类型)、unit_price(单价)、policy_name(策略名称)、effective_date(生效日期)等。
2. 业务逻辑层面
核心思想:定义一个统一的 BillingStrategy接口,其中有一个 calculateCost(double usage)计算方法。
执行流程:当用户进入缴费页面时,系统:
a. 根据用户ID获取其 user_type。
b. 通过一个 BillingStrategyFactory(策略工厂),根据 user_type返回对应的计费策略对象(例如,返回 IndustrialBillingStrategy实例)。
c. 调用 strategy.calculateCost(usage)方法,自动执行该用户类型特有的计费逻辑。
1. 事务隔离级别 (Isolation Level)
“我选择的是 Isolation.READ_COMMITTED(读已提交)。这是基于以下考虑:
业务场景分析:价格调整通常是管理员操作,频率很低,而并发查询和生成账单的频率很高。我们首先要防止在价格调整过程中,其他事务读到未提交的、可能被回滚的中间状态(即脏读)。READ_COMMITTED可以有效避免脏读。
业务场景分析:价格调整通常是管理员操作,频率很低,而并发查询和生成账单的频率很高。我们首先要防止在价格调整过程中,其他事务读到未提交的、可能被回滚的中间状态(即脏读)。READ_COMMITTED可以有效避免脏读。出现读时序异常,在下一次查询时就会正常。
事务传播机制 (Propagation Behavior)
价格调整通常是一个独立的业务操作,我设计其传播机制为 Propagation.REQUIRED。
设计理由:这是最常用且合理的默认设置。它意味着:
如果当前已经存在一个事务,方法就在这个事务内运行。
如果当前没有事务,就新建一个事务。
在价格调整中的体现:价格调整操作(例如,先使旧价格失效,再使新价格生效)本身就是一个原子单元,必须全部成功或全部失败。使用Propagation.REQUIRED可以确保无论它被谁调用,都会在一个事务的保护下执行,从而保证‘失效’和‘生效’两步操作的数据一致性。
关于其他传播行为:您问题中提到的‘一个事务调用另一个事务’的场景,如果需要强隔离,可能会用到 Propagation.REQUIRES_NEW。例如,在价格调整的核心逻辑中,如果需要记录一个独立的、必须成功的审计日志,即使价格调整本身失败,日志也要留存,那么记录日志的方法就需要 REQUIRES_NEW来开启一个独立的新事务。但在单纯的价格调整主流程中,REQUIRED是最佳选择。”这意味着,如果在一个事务中调用另一个标记为REQUIRES_NEW的方法,当前的事务会被挂起,而新的事务会被创建并执行该方法。

1. 用户表 (user)
设计目的:存储用户基本信息。
核心字段:
user_id(主键):用户唯一标识。
user_type(枚举):用户种类,如 RESIDENTIAL(居民)、COMMERCIAL(商业)、INDUSTRIAL(工业)。使用枚举类型是为了约束和明确业务含义。
2. 气价表 (gas_price)
设计目的:支持气价的动态调整和历史追溯,这是系统的核心配置。
核心字段:
price_id(主键):价格策略ID。
user_type(枚举):与用户表关联,标识此价格适用于哪类用户。这实现了价格与用户的绑定。
effective_time(DateTime):价格的生效时间。这是关键字段,通过它来实现价格的平滑切换。
status(枚举):状态,如 ACTIVE(生效)、INACTIVE(失效)、PENDING(待生效)。
3. 账单表 (billing)
设计目的:记录每个计费周期的用户账单,是核心业务数据。
核心字段:
billing_id(主键):账单ID。
user_id(外键):关联用户。
billing_cycle(Date):账单周期,如 '2023-10'。
gas_usage(Decimal):本期用气量。
amount(Decimal):本期应付金额。这里存储计算后的最终结果,是典型的‘空间换时间’优化,避免每次查询都实时计算。
price_id(外键):记录计费时使用的气价ID。这至关重要,保证了即使未来气价变更,也能准确追溯历史账单的计算依据。
status(枚举):账单状态,如 UNPAID(未付)、PAID(已付)、OVERDUE(逾期)。
3.交易记录表 (transaction)
设计目的:记录每一笔支付流水,实现财务可追溯。
核心字段:
transaction_id(主键):交易ID。
billing_id(外键):关联所属账单。
user_id(外键):关联用户(反范式设计,方便快速查询用户的所有交易)。
paid_amount(Decimal):实付金额。
payment_method(枚举):支付方式,如 ALIPAY, WECHAT, BANK。
paid_at(DateTime):支付时间。
transaction_no(Varchar):第三方支付流水号,用于对账。
1. 查询性能优化
索引策略:
在所有外键字段(如 billing.user_id, transaction.user_id)上建立索引,加速表连接查询。
2.数据一致性保障
事务控制:在生成账单和完成支付的核心流程中,使用Spring的 @Transactional注解保证数据库操作的原子性。例如,支付成功后,需要同时更新 transaction表和 billing表的 status,这两个操作必须在同一事务中。

核心问题分析
当多个管理员同时调整价格时,会引发典型的“写-写”并发冲突。最直接的表现是后提交的操作会覆盖前一个操作,导致数据错乱、业务逻辑异常,甚至产生错误的定价
方案一:乐观锁
乐观锁的核心思想是:假设并发冲突不常发生,只在数据提交更新时检查此期间是否有其他操作修改过该数据。
操作流程:
读取时:当管理员A查询一条价格记录准备修改时,同时获取当前的 version值(比如 version = 1)。
冲突处理:
无冲突时:如果这条记录在A读取后未被其他人修改,version还是1,则更新成功,同时 version变为2。
UPDATE gas_price
SET price = {new_price}, version = version + 1
WHERE price_id = {target_id} AND version = {old_version};
// 这里的 old_version 就是最初读到的 1
有冲突时:如果管理员B在A提交前已经更新了该记录,那么数据库中的 version已经变成了2。此时A的更新语句(WHERE version = 1)将匹配不到任何数据,更新返回的影响行数为0。
方案二:悲观锁
悲观锁的核心思想是:假设并发冲突一定会发生,因此在修改数据之前就直接将其锁住,阻止其他操作。
BEGIN; -- 开启事务
SELECT * FROM gas_price WHERE price_id = {target_id} FOR UPDATE; -- 获取行锁
这条 SELECT ... FOR UPDATE语句会为这条记录加上排他锁。在此期间,任何其他尝试修改或使用 FOR UPDATE查询此记录的事务都会被阻塞,直到管理员A的事务提交或回滚。
管理员A完成修改后,执行 COMMIT;释放锁。
优点:能彻底解决并发冲突问题,保证强一致性。
缺点:性能开销大,容易导致大量请求阻塞,降低系统吞吐量。不推荐在价格管理这种场景下使用。
此外,作为辅助手段,我会记录详细的操作日志,包括操作人、时间、修改前后的值等,以便在出现问题时进行追踪和审计。
究极问题,项目的改进:

一、 系统架构设计
核心设计思想是:解耦、削峰、异步化和冗余。
用户服务
价格服务 (核心)
订单/交易服务 (核心)
秒杀活动服务 (核心中的核心)
核心组件与技术选型
网关层:使用 Spring Cloud Gateway 或 Nginx 作为全局网关,负责路由、负载均衡、限流和鉴权。
缓存层:使用 Redis 集群。这是应对高并发的利器。
消息队列:使用 RocketMQ 或 Kafka。它们是实现异步化和削峰填谷的核心。
业务服务层:使用 Spring Cloud 系列的微服务组件。
数据库层:采用分库分表的 MySQL 集群。
核心场景流程设计
a) 实时价格调整
设计:价格信息作为全局性的基础数据,变更不频繁但读取极其频繁。
流程:
管理员在后台通过价格服务更新数据库中的价格。
价格服务更新数据库后,主动更新 Redis 缓存(直接覆盖Key)。
所有查询价格的请求(包括前端展示和下单时计算金额),不再访问数据库,直接读取Redis缓存。
优势:性能极高,能轻松应对海量读取请求,保证价格的实时性。
库存预热:活动开始前,将秒杀气量的库存(比如10000份)提前加载到 Redis 中。例如
seckill:stock:activity_001 = 10000。请求拦截:
网关层限流:在网关层对秒杀接口进行严格限流,丢弃超过系统承载能力的请求。
验证与过滤:对请求进行恶意访问校验、用户资格校验等。
核心下单逻辑:
用户点击“秒杀”,请求进入秒杀服务。
秒杀服务不直接操作数据库,而是执行一个 Redis 原子操作:
如果扣减成功,意味着用户“抢到了资格”。此时,服务会立即返回“抢购中,请等待结果”,而不同步生成订单。
异步创建订单:
秒杀服务将“创建订单”的消息(包含用户ID、活动ID等)发送到 RocketMQ/Kafka 消息队列中,然后立即返回给用户。
优势:将耗时的数据库操作(创建订单记录、支付记录)异步化,实现了削峰,系统吞吐量得到巨大提升。

浙公网安备 33010602011771号