interview

本interview.md用于面试,目前出于内容收集期

self.logger: 失去的“权力”与应对策略

  1. 静态方法与类方法的日志权
    self.logger是实例属性,@staticmethod@classmethod内部没有self
    约束:未来如果Service层需要写工具性质的静态方法或工厂类方法,这些方法无法使用self.logger
    当前项目的风险评估:极低。目前Service层设计为纯实例方法,没有静态方法的需求。私有方法也是实例方法,可以直接用self.logger
    应对策略:如果未来真的出现这种场景,在同一个类里混用self.logger和模块级logger会违反宪法一致性。届时更合理的做法可能是将该静态功能独立成一个工具函数,并将 logger作为参数显式传入。

  2. 模块级函数与工具函数的日志权
    约束:如果未来从Service中提取出独立的工具函数(如复杂的格式校验、数据清洗),它们脱离了Service实例,无法使用self.logger
    当前项目的风险评估:中等。但这是逻辑解耦的必然代价。
    应对策略:这正是“显式优于隐式”原则的体现。对于独立函数,应该通过参数传入logger或返回错误由调用方记录日志。这避免了独立函数对特定logger的硬依赖,反而更灵活。

  3. 跨模块通用逻辑的日志权
    约束:如果开发一个通用的校验器基类或Mixin,供多个Service继承,该如何在基类中打日志?是用self.logger还是模块级?如果用self.logger,则强制子类必须设置该属性;如果用模块级,基类日志就绑定了自己的模块路径,子类日志上下文会混乱。
    当前项目的风险评估:低。根据架构,我们目前不计划使用复杂的继承体系,而是通过组合(私有方法)来实现代码复用。
    应对策略:如果真的需要复用,可以使用组合模式,让通用组件在初始化时接收一个logger实例,这是标准的依赖注入,保持了日志来源的清晰可控。

  4. 异步编程下的上下文传递
    self.logger本身是线程安全的,但关键在于我们手动传递的extra字典。
    约束:未来引入asyncio时,当前通过extra={"method": "xxx", "sku": sku}手动传递上下文的方式,在一个async/await交错的环境中会变得脆弱且易出错,因为你必须手动确保在异步切换点前后,extra里的值仍然是正确的。
    当前项目的风险评估:中等。
    应对策略:业界最佳实践是使用contextvars自动记录和传递上下文。届时,我们需要写一个轻量级的适配器,让日志调用从contextvars中自动抓取trace_idmethod等字段。self.logger依然是那个logger实例,只是extra的组装方式从“手动拼接”进化成了“自动填充”。

  5. 与可观测性系统的集成成本
    约束:未来对接OpenTelemetry等自动注入trace_id的工具时,业界标准做法是猴子补丁修改模块级logging模块的行为。我们使用self.logger,它本身也是标准的logging设施,所以不存在兼容性问题。
    当前项目的风险评估:极低。
    应对策略:self.logger完全兼容标准logging生态,只是可能需要在配置自动注入时进行一些额外的适配,但不存在“无法集成”或“失去权力”的问题。

Q & A

Q: 项目中用了实例级logger,而不是模块级的,能解释一下吗?
A:
(第一层,战术需要):
为了可测试性。在这个项目里,我们视日志为系统契约的一部分,而不仅仅是排障的副产品。我们必须能在单元测试里精确断言logger.error没有被意外调用、或者logger.warning恰好被调用了一次。模块级logger的mock是脆弱且依赖实现的,实例级self.logger让我们能一行替换,实现稳定、干净的日志行为验证。
(第二层,战略高度):
这背后,是我们项目的核心设计原则——我们主动选择了将日志行为上升为业务契约来对待。当一条INFO日志被定义为‘金融审计的唯一证据’,一条WARNING日志被定义为‘业务异常监控的触发信号’时,它们的‘有无’和‘次数’就和数据库里的数据一样,是不可或缺的、必须被精确验证的系统状态。我们是用‘非主流写法的微小代价’,换来了‘系统可观测性可验证’的巨大收益。
(第三层,终极反杀):
当然,这个选择仅限于Service层的业务逻辑编排。我们非常清楚它在静态方法、序列化等方面的局限性。但在当前架构下,这些代价都不会被触发。这是在有明确上下文边界下的刻意的、有纪律的技术取舍,而不是一个无知的错误。

私有方法代替DTO完成业务逻辑校验,以保证架构的纯洁性

未来演进路径

  • 起点:模块化单体
  • 第一步:拆分库存并用GO重写
  • 第二步:Redis缓存
  • 第三步:MySQL主从/读写分离
  • 第四步:异步/消息队列
  • 第五步:拆分订单并重写 (Python)。刚需流式处理时用Java重写订单

引入Redis。基于当前系统、单人开发的条件,引入Redis意味着需要面对

  • 缓存Key的命名规范
  • 缓存与数据库的双写一致性
  • 缓存穿透、击穿、雪崩的防护
  • 单元测试中,Mock掉所有Redis调用
  • 集成测试中,管理Redis容器的生命周期

引入主从,这意味着需要面对

  • 读写分离的路由规则(怎么判断一条SQL该走主库还是从库)
  • 主从延迟导致的写后读不到问题
  • 从库挂掉时的降级策略

项目亮点描述(草案)

  • 不依赖框架隐式能力,手动实现Service层的“防御-异常-观测”三位一体保障体系,对数据完整性和系统可观测性实施白盒级控制
  • 精准微框架 + 显式工程纪律
  • 在AI辅助编程时代,ModM 选择了一条“反碎片化”的道路。我们不依赖框架的隐式行为来加速功能交付,而是通过一套严格的工程宪法,确保每一行由人机协作生成的代码,都具有完全的透明度与可解释性。这降低了系统的长期认知负债,使开发者始终保持对代码行为的知情权

更贴近简历项目描述的项目亮点

电商数据管道(模块化单体架构)

  • 从零设计并落地了三层架构的施工规范(CONVENTIONS.md),覆盖分层职责、事务边界、日志体系、并发控制、测试策略等全部环节
  • 实现了基于乐观锁的库存防超卖机制,通过原子化SQL + 版本号实现并发扣减的零超卖保障
  • 建立了完整的分层防御体系:Repo层原子操作、Service层事务编排、API层决策,每层边界清晰、职责单一
  • 全路径测试覆盖,每种异常分支、每次事务回滚、每条日志输出均被精确断言,测试即文档

八股收集

什么是乐观锁?和悲观锁有什么区别?

  • 乐观锁不是‘数据库技巧’,而是一种并发控制策略。在代码里,乐观锁本质是带着一个版本号去写,写的时候检查版本号是否还是之前读到的那个。悲观锁则用SELECT ... FOR UPDATE锁行。我在项目里,价格更新用乐观锁(version字段),库存扣减用条件更新(WHERE stock >= quantity),后者是乐观锁思想在业务字段上的应用。

乐观锁具体怎么实现?SQL怎么写?

  • 在SQLAlchemy里,我用Core风格写:update(table).where(version == old_version).values(version=table.c.version + 1)。关键点是版本自增必须在数据库侧做(version + 1),不能在应用层算好再传,避免并发脏读。如果rowcount == 0,我在Service 层抛一个自定义的ProductConcurrentUpdateError,而不是让数据库异常直接透出。

乐观锁的冲突检测放在哪一层?Service还是Repo?

  • 我采用显式分层。Repo层只做原子操作:UPDATE ... WHERE version = ?,冲突了返回None,不抛异常。Service层拿到None后,根据业务上下文判断为并发冲突,抛出业务异常ProductConcurrentUpdateError。这样Repo层不感知业务语义,Service层掌握事务控制权和异常决策权。测试时,分别验证Repo返回None和Service抛异常。

乐观锁有什么坑?有没有什么场景不适合用?

  • 第一个坑是审计日志带来的额外读。如果业务要求记录旧值(比如价格变更审计),乐观锁就得先SELECT再UPDATE,打开一个并发窗口。这个窗口虽然被version保护,但代码复杂度上去了。对比我们项目的库存扣减,它不需要旧值,直接用WHERE stock >= quantity一步完成,就不需要这个窗口。
  • 第二个坑是版本号必须从事实源取。我遇到过用业务字段(old_price)充当版本号的方案,它只能检测该字段的变更,其他字段被改了检测不到。后来升级到专用version字段才解决。
  • 第三个坑是重试策略。如果冲突了,是直接抛异常让用户重试,还是自旋重试?这要看业务——价格更新我们直接抛异常,因为后台管理系统操作频率低,用户手动重试即可;秒杀库存就不能这么干,得用消息队列削峰。

(反问)贵司的乐观锁在哪些场景落地?版本号是数据库字段还是分布式版本向量?冲突后的重试策略是自旋、退避还是直接抛异常?有没有遇到过审计日志驱动下‘先读后写’带来的窗口问题?

  • 由对方回答。

乐观锁具体怎么实现?SQL怎么写?

  • 在库存表增加version整数字段。
  • Repo层使用Core风格写原子化SQL:
  • UPDATE inventory SET reserved_quantity = reserved_quantity + ?, version = version + 1
  • WHERE product_id = ? AND version = ? AND quantity - reserved_quantity >= ?;
  • 关键点:版本自增在数据库侧完成(version + 1),不在应用层算好再传,避免并发脏读。
  • 如果rowcount == 0,Repo 返回None。Service层根据上下文判断为版本冲突或库存不足,分别抛出StockConcurrencyError或透传InsufficientStockError

什么是CAS(Compare And Swap)?

  • CAS是一种无锁的并发控制原子操作,是乐观锁的底层核心思想。
  • 涉及三个操作数:内存位置(要修改的数据)、期望值V_old(你认为数据应该是什么)、新值V_new(你希望更新成什么)。
  • 原子逻辑:如果当前位置的值等于V_old,则更新为V_new,返回成功;否则什么也不做,返回失败。
  • 在我们项目中的体现:
  • Compare:WHERE version = ?(检查版本是否还是旧值)
  • Swap:SET version = version + 1(更新为新版本)
  • 原子性:两个动作在同一条SQL中完成,数据库保证不被中断

如何防止库存超卖?

  • 三层联动构成“完全体”:
  • 数据库层(Repo):通过带version字段的原子化SQL,将“判断库存是否充足”和“执行扣减”合并为一个原子操作,保证基于最新数据做决策
  • 业务层(Service):检测Repo返回的影响行数。为0时区分两种情况:库存不足抛InsufficientStockError,版本冲突抛StockConcurrencyError,同时记录WARNING日志
  • 接口层(API):决定冲突处理策略。库存不足直接返回给客户端;版本冲突可自动重试或返回“系统繁忙,请重试”

库存释放操作为什么也需要乐观锁?

  • 防止基于过期库存快照执行释放,导致数据不一致
  • 更重要的是防止重复释放:超时取消任务因网络抖动或重试被执行两次时,version 乐观锁能让第二次操作因版本不匹配而静默失败,天然提供接口幂等性的雏形

并发策略

悲观锁

  • 假设冲突一定会发生,所以在动手前,先把资源锁住,不让别人碰。
  • [核心机制]
  • 数据库悲观锁 (Pessimistic Locking):使用 SELECT ... FOR UPDATE 显式锁定目标数据行。在事务提交前,其他事务无法读取或修改这些行
  • 应用级互斥锁 (Mutex/Lock):在编程语言层面(如 Python 的 threading.Lock)控制,同一时刻只有一个线程能执行某段代码
  • [适用场景]
  • 冲突激烈
  • 需保护“读-思考-写”这个漫长过程的完整性,如银行转账
  • [代价]会阻塞其他操作,降低系统吞吐量,甚至可能导致死锁

乐观锁

  • 假设冲突是小概率事件,先大胆地做,提交时再检查有没有人捣乱
  • [核心机制]
  • 数据版本控制:给数据加一个版本号或时间戳,更新时作为条件
  • 冲突检测:UPDATE ... SET version = version + 1 WHERE version = :my_read_version。如果 rowcount 为 0,说明冲突了
  • 失败处理:立即失败,还是原地“自旋”重试,或是把任务丢到消息队列延后处理
  • [适用场景]
  • 读多写少,冲突概率低
  • 不希望锁阻塞其他读操作
  • [代价]一旦冲突,操作就失败了,需要上层有合适的重试或降级策略

原子操作

  • 设计一个操作,在数据库引擎里一步到位,物理上不可分割
  • [核心机制]
  • 数据库原子更新:一条SQL搞定一切,比如 UPDATE inventory SET stock = stock - ? WHERE sku = ? AND stock >= ?
  • CPU 原子指令 (CAS):底层硬件提供的“比较并交换”指令,是很多高级并发工具的地基。应用层的“自旋”就是反复调用 CAS 指令直到成功
  • [适用场景]
  • 逻辑很简单,一个条件就能判断(比如库存还够不够)
  • [代价]只适用于简单的业务逻辑,复杂场景下很难用一条SQL表达

隔离

  • 直接不给并发机会。每个操作处理自己的那份数据,最后再把大家的成果合并起来,有效规避冲突
  • [核心机制]
  • Actor 模型:每个“Actor”有自己的私有状态,只通过收发消息通信。同一时刻只处理一条消息,天然无竞争
  • 无冲突复制数据类型 (CRDT):为分布式系统设计的数据结构,保证在无锁的情况下,多个副本最终能达成一致。比如你和一个朋友同时编辑一个文档的不同行,两人的修改都能被接受,不会冲突
  • [适用场景]
  • 分布式、去中心化系统
  • 需要极高可用性和容忍网络分区的场景(如在线协作应用)
  • [代价]编程模型复杂,不是传统的“读-改-写”范式

项目问题收集

Modular Monolith中如何处理跨域数据访问?

  • 不允许在Repo层内部直接调用其他模块的 Repo 或JOIN其他模块的表。低层模块之间禁止形成横向依赖网
  • 正确做法:在Service 层通过依赖注入持有其他模块的Repo实例,按顺序编排多个领域的Repo方法,完成跨域业务流程。例如InventoryService注入ProductRepo,先通过ProductRepo.get_by_id()确认商品存在,再通过InventoryRepo查询库存
  • 原则:依赖倒置——高层模块依赖低层模块的接口,而非低层模块之间互相依赖

不同模块定义了同名异常(如ProductNotFoundError),如何处理?

  • 每个模块在src/exceptions/ 目录下独立管理自己的异常文件(如product_exceptions.py、inventory_exceptions.py)
  • 模块内部使用简洁的类名(如ProductNotFoundError),无需前缀
  • 当两个同名异常需要在同一文件中共存时,通过 Python的as关键字在导入侧重命名以消除歧义:
  • from src.exceptions.inventory_exceptions import ProductNotFoundError as InventoryProductNotFoundError
  • 原则:每个模块拥有自己的异常定义,不直接复用其他模块的异常类。将来拆分微服务时,各模块的异常文件直接属于各自的服务代码库,无需改动

Repo层为什么允许ORM和Core混用?怎么选择?

  • ORM风格:适合简单查询 + 对象映射,如select(Product).where(...),自动水合为领域对象,代码最简洁
  • Core风格:适合复杂查询、聚合统计、批量写操作,如select(product_table)update(table).where(...),精确控制SQL,性能更好
  • text() 原生SQL:适合复杂聚合或多表子查询,是Core工具箱的一部分,可在最小必要范围内使用
  • 同一个Repo文件中,每个方法只使用一种风格,不在同一方法内混搭
  • 原则:因地制宜,为每个查询选择最合适的工具。ProductRepo已经走了这条路,InventoryRepo继承同样的实践

业务异常和操作异常的区别?分别放在哪里?

类型 定义 抛出者 存放位置 示例
业务异常 业务逻辑不允许的情况 Service层 src/exceptions/目录 ProductNotFoundError、InventoryValidationError
操作异常 无法执行操作本身 Repo 层 Repo文件内部 InsufficientStockError

  • Repo层只抛出操作异常(物理约束不满足,如库存不足无法扣减)。
  • Service层只抛出业务异常(业务规则不允许,如商品不存在)。
  • 操作异常可被Service层捕获后透传或转换为业务异常再抛出

反问环节问题收集

  • 如果进面后,诊断对方的需求是人才熟识工程治理,那么反问环节的问题应跟权力结构紧密相关,工程治理需要权限下放
  • 以下问题旨在把握团队的本质:
  • [问技术负责人]如果我发现一个核心模块的代码需要重写才能支撑下个季度的需求,但不是这个迭代的计划内,我们有什么流程来处理这种事?之前有过成功案例吗?.
  • 如果对方开始谈“灰度发布”、“风险评估”,说明有戏。如果对方打哈哈说“我们很敏捷,到时候再说”,说明没有
  • [问未来的同事]我们现在的代码,有什么地方是你们特别想改但一直没机会改的吗?
  • 如果他们眼睛放光,开始吐槽某个“传奇屎山”,那说明团队有技术追求但被压制了。如果他们一脸茫然,说“都挺好的”,那说明这个团队对混乱已经麻木了
  • [问管理者(CTO/技术总监)]这个岗位,是希望我去解决具体的、明确的技术问题,还是去定义一个尚未明确的、关于‘我们如何写出更好代码’的长期路线图?
  • 这是最关键的“生态位”确认。如果他清晰地说后者,并且能说出他现在的痛点是“系统越来越脆弱、改不动”,那么权力是可以谈的。如果他只是说“我们缺一个能干活的资深开发”,那这个位置就很危险