天然气销售优秀的系统模拟面试

项目背景:






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缓存。

优势:性能极高,能轻松应对海量读取请求,保证价格的实时性。

  1. 库存预热:活动开始前,将秒杀气量的库存(比如10000份)提前加载到 Redis​ 中。例如 seckill:stock:activity_001 = 10000

  2. 请求拦截

    • 网关层限流:在网关层对秒杀接口进行严格限流,丢弃超过系统承载能力的请求。

    • 验证与过滤:对请求进行恶意访问校验、用户资格校验等。

核心下单逻辑:

用户点击“秒杀”,请求进入秒杀服务。

秒杀服务不直接操作数据库,而是执行一个 Redis 原子操作:

如果扣减成功,意味着用户“抢到了资格”。此时,服务会立即返回“抢购中,请等待结果”,而不同步生成订单。

异步创建订单:

秒杀服务将“创建订单”的消息(包含用户ID、活动ID等)发送到 RocketMQ/Kafka​ 消息队列中,然后立即返回给用户。

优势:将耗时的数据库操作(创建订单记录、支付记录)异步化,实现了削峰,系统吞吐量得到巨大提升。

posted @ 2025-12-22 20:44  gccbuaa  阅读(0)  评论(0)    收藏  举报