本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生态,只是可能需要在配置自动注入时进行一些额外的适配,但不存在“无法集成”或“失去权力”的问题。

本项目的“反常识点”一览

架构范式层面的反常识

三分层架构在Python生态中显式落地

  • [主流认知] Python Web项目 = Django/Flask/FastAPI + ORM + 模板,”分层”最多是”把逻辑从view里抽出来放个utils.py”
  • [当前项目的做法] 显式的Repositories → Services → API三层,每层有严格的职责边界、禁止事项清单。这确实是Java/Spring生态的惯用模式,在Python圈里属于”重量级”做法
  • [反常之处] Python社区崇尚”简洁”、”实用”,认为三层架构是过度工程化。一个Flask项目可能300行全在一个文件里就完了
  • [如何自圆其说] 为了模块边界的可拆分性——这是Modular Monolith的核心前提。Python的灵活性恰恰是大型项目的敌人,需要主动引入纪律

放弃ORM的DDL能力,用原生SQL文件管理表结构

  • [主流认知] 用SQLAlchemy就是为了让Base.metadata.create_all()自动建表,模型即表结构,一处定义,处处使用
  • [当前项目的做法] 01-schema.sql显式DDL,Docker初始化时执行。ORM只用于DML和对象映射
  • [反常之处] 大部分Python项目把ORM的DDL能力当作”标配福利”,我们直接放弃
  • [如何自圆其说] 数据库结构是系统最底层的契约,必须完全显式、可审计 / ORM自动生成的DDL可能带有隐式行为(如存储引擎、字符集的默认值) / DBA和运维可以直接阅读和修改SQL文件,不需要懂SQLAlchemy

显式构造器注入 + 禁止类内部自行获取依赖

  • [主流认知] Python里最”顺手”的依赖管理是from config import db_session或者用Flask的g对象、FastAPI的Depends()。甚至直接在函数内部get_db()
  • [当前项目的做法] 所有依赖必须通过__init__声明,由外部组装点注入。禁止任何形式的内部自行获取
  • [反常之处] DI容器在Python里不是标配,很多人觉得”搞那么复杂干嘛,import一下不就行了”
  • [如何自圆其说] 可测试性。如果类内部自己调get_db(),测试时就无法替换成mock,只能依赖真实数据库。构造器注入让依赖显式化、可替换,是单元测试的基础

职责划分层面的反常识

Repo层不记日志、不提交事务

  • [主流认知] 数据库操作出错了就在Repo里logger.error,操作完了顺手commit(),一步到位
  • [当前项目的做法] Repo层:禁止commit() / rollback(),禁止logging,只允许flush()供后续操作透视
  • [反常之处] 这相当于给Repo戴上了”手铐”——明明可以做的事,偏不让你做。很多开发者会觉得”这太死板了,我就在Repo里记个日志怎么了?”
  • [如何自圆其说] 事务边界必须由Service层统一控制,保证业务完整性 / 日志是业务可观测性的组成部分,应该由理解业务上下文的Service层来记录 / Repo层保持纯粹,只做数据操作,不附带任何”副作用”(日志是I/O副作用)

Repo层禁止公开方法互相调用

  • [主流认知] 一个Repo方法里需要另一个查询,直接self.get_by_id(xxx),代码复用,多方便
  • [当前项目的做法] Repo公开方法之间禁止互相调用。这会在Repo层内部形成”服务总线式”的依赖网
  • [反常之处] 这和DRY原则看起来冲突——明明有现成的方法可以用,却要”重复”查询逻辑?
  • [如何自圆其说] Repo方法是原子数据操作,每个方法应该独立、可替换、不依赖同层其他方法。如果允许互相调用,重构一个方法会级联影响多个方法,这破坏了”原子性”,也让未来拆分模块变得更困难

“下游不信任上游”:跨域调用的入参必须重新校验

  • [主流认知] 内部调用是”可信”的——Service A调Service B,A传的参数肯定没问题,B直接用就行
  • [当前项目的做法] 即使是编排链下游的Service,也必须对所有原始入参进行完整的格式校验,不能因为”数据来自上游”就跳过
  • [反常之处] 这看起来是”不信任自己人”,增加了”不必要的”校验开销
  • [如何自圆其说] 模块边界是未来的物理边界。今天A和B运行在同一个进程,明天可能就拆分到不同服务。如果B隐式信任A的传参,拆分后就需要额外的工作去补校验逻辑。在模块边界处显式校验,是对未来拆分的前置投资

“禁止意图合并”:跨域校验和数据获取必须独立调用

  • [主流认知] 查一次数据库能同时验证”记录存在”和”获取数据”,何乐不为?比如用get_version_snapshot的返回值是否为None来”顺带”判断SKU对应的商品是否存在
  • [当前项目的做法] 商品存在性必须通过ProductRepo.get_by_sku()验证,版本快照必须通过InventoryRepo.get_version_snapshot()获取。两个事实源必须独立查询,禁止用一个调用的返回值推断另一个业务事实
  • [反常之处] 违背了”减少数据库查询次数”的性能直觉
  • [如何自圆其说] 意图清晰 > 性能微优化。每次数据库查询的意图必须单一且显式。合并查询会引入隐式耦合——如果未来get_version_snapshot的实现发生变化(例如不再返回None而是抛异常),依赖其返回值进行推断的逻辑就会静默失效。性能优化应该在有profile数据支撑后,在保持接口清晰的前提下进行

测试哲学层面的反常识

禁止用parametrize糅合不同业务场景

  • [主流认知] pytest的parametrize是消灭重复测试代码的利器,把所有类似场景用参数表驱动
  • [当前项目的做法] 当两个测试场景的业务意图或验证边界有本质区别时,即使代码高度相似,也必须保留为独立的测试方法。单个测试函数只验证一种故障场景
  • [反常之处] 这直接违背了DRY原则,会产生大量”看起来重复”的测试代码
  • [如何自圆其说] 测试代码的读者不是机器,是未来调试bug的人(通常是你自己) / 当一个测试失败时,你希望它的名字直接告诉你”什么场景挂了”,而不是去parse参数组合 / 这是DAMP原则(Descriptive And Meaningful Phrases)的实践——测试代码中,可读性碾压可复用性

测试Repo层读方法只校验id和sku,不对所有字段断言

  • [主流认知] 测试应该”全面”,返回的对象里每个字段都得验一遍,才算”覆盖”
  • [当前项目的做法] 只校验id和sku确认记录一致性,其余字段的正确映射由models.py的单独测试保证
  • [反常之处] 看起来是”偷懒”,测试不够”彻底”
  • [如何自圆其说] 测试要验证的是接口契约,而不是实现细节。ORM字段映射的正确性是一个独立的关注点,应该有自己的测试。把这个和Repo读方法的测试混在一起,会导致当字段映射不变、但读方法本身出现bug时,测试的错误信息不够精准

哲学层面的反常识

“开发者即框架”

  • [主流认知] 框架是来帮我们省事的。Django的admin、FastAPI的自动验证、Spring的AOP——框架越强大,我写的代码越少
  • [当前项目的做法] 所有框架提供的能力,必须在代码中显式落地。框架不允许替我们做任何”隐式”的决定
  • [反常之处] 这看起来是”造轮子”——放着现成的框架能力不用,非要手写校验、手写事务管理
  • [如何自圆其说] 系统行为必须白盒化。框架的隐式行为是技术债的最隐蔽来源——它们在你不知情的情况下运行,出问题时你无法快速定位。当项目从单体拆分为微服务时,框架行为在不同服务间的差异会带来巨大的心智负担。我们选择付出”多写代码”的成本,换取系统行为的完全可预测

“数据真实性”:内存状态无权威性

  • [主流认知] 代码里算出来的值、对象的属性、方法的返回值——这些就是”事实”,直接拿来用就行
  • [当前项目的做法] 任何内存状态、计算结果、对象属性都不具备最终权威性。只有成功提交并持久化的数据库状态才是唯一可靠的判定依据。测试中必须回查数据库验证
  • [反常之处] 这挑战了程序员对”代码正确性”的基本信任
  • [如何自圆其说] ORM的隐式行为(如flush时机、cascade、expire)、并发窗口、事务隔离级别——这些都可能导致内存状态与数据库实际状态不一致。盲目信任内存状态,等于在系统中埋下不可复现的bug。悲观假设,显式验证

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该走主库还是从库)
  • 主从延迟导致的写后读不到问题
  • 从库挂掉时的降级策略

到充血模型的演进

Step1.模型充血

  • 在ORM对象上增加行为方法。让InventoryProduct等SQLAlchemy ORM对象,不再是纯数据容器,而是拥有自己的行为方法
  • 领域对象负责保护自己的状态一致性,不让外部随意修改字段
  • Service层从”逻辑执行者”退变为”逻辑编排者”——调用inv.reserve(qty),然后委托Repo持久化
  • 不引入新目录、不拆分模块,只在现有models.py上做加法

Step2.边界显式化

  • 分离domain/ 和infrastructure/
  • 当ORM对象上的业务逻辑膨胀到一定程度,models.py开始混杂”数据库映射”和”业务规则”两种职责时,引入独立的domain/ 目录,领域对象与ORM彻底解耦
  • 注,以上是普适框架的、引入DDD的触发条件
  • 本项目的DDD引入触发条件是:当开发者意识到,当前的模型架构已经无法清晰表达某个业务复杂度时,主动引入Domain层来提供更丰富的表达工具
  • 例如,
    inventory/
    ├── domain/
    │ ├── entities.py # Inventory 纯Python 类,不继承 Base
    │ └── value_objects.py # StockQuantity等 值对象
    ├── infrastructure/
    │ ├── models.py # SQLAlchemy ORM模型,仅负责映射
    │ └── repositories.py # 负责domain ↔ ORM转换
    └── services/
    └── inventory_service.py # 应用服务,编排 + 事务
  • Service层从此与SQLAlchemy零依赖,只操作领域对象和Repo接口
  • 同时,这就也模块化单体的高级形态:如果某天要拆成微服务,只需替换Repo实现,领域对象和Service层一行不改

Step3.按需引入领域事件

  • 触发条件:当多个模块之间的编排逻辑变得复杂(如”订单创建 → 库存预留 → 补货告警”)
  • 将Service中的同步跨模块调用,替换为领域事件的发布/订阅
  • 当前项目的审计日志(_insert_price_change_log)本质上是事件的雏形——同步、同模块、持久化的”事件记录”
  • 升级为事件驱动时,只需把这条记录提升为跨模块消息,基础设施已有预留
  • 例如,
  • 之前(同步编排)
    def create_order(self, ...):
    order = self.order_repo.create(...)
    self.inventory_repo.reserve(...) # 同步耦合
  • 之后(事件驱动)
    def create_order(self, ...):
    order = self.order_repo.create(...)
    event_bus.publish(OrderCreatedEvent(order_id=order.id, ...)) # Inventory模块订阅该事件,异步处理库存预留

项目亮点描述(草案)

  • 不依赖框架隐式能力,手动实现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):决定冲突处理策略。库存不足直接返回给客户端;版本冲突可自动重试或返回“系统繁忙,请重试”

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

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

Python 如何处理海量数据库查询,避免内存溢出

  • 核心策略是流式处理
  • [场景] 需要从数据库读取千万级数据进行处理,使用fetchall()可能导致服务器内存耗尽(OOM)
  • [解决方案] 使用SQLAlchemy的yield_per()方法
  • [底层实现] 它将查询结果变成一个生成器,每次只从数据库获取一批数据(比如1000条),处理完再获取下一批。这保证了内存占用量始终是恒定的,但会增加数据库的连接和事务时间
  • [项目实现] 当前的业务场景(如库存操作)都是针对单条或少数几条记录,使用fetchall()就能获得最佳性能和最简洁的代码,无需引入流式处理。当未来项目需要处理大数据量ETL任务时,将会第一时间采用yield_per()这个方案来保障系统的稳定性

什么是IoC容器?

  • IoC即控制反转(Inversion of Control)
  • 传统的代码里,一个类如果需要数据库连接,它得自己主动去创建new DatabaseConnection()。这叫“主动获取”,控制权在这个类自己手里
  • 但在IoC的世界里,这个类什么都不用干。它通常只需要在构造函数中声明:“我需要一个数据库连接”。然后,一个叫做IoC容器的“超级管理员”会负责:
  • 在系统启动时,把所有“员工”(组件,如Service, Repository)和“工具”(资源,如数据库连接)都创建好并登记在册
  • 当发现某个对象(比如一个Service)需要工具(数据库连接)时,它就会主动把工具注入给到需要工具的对象
  • Spring Framework的核心,就是一个超级强大的IoC容器。它管理着成千上万个Java对象的生命周期和它们之间的依赖关系
  • 我们通过CONVENTIONS强制执行“Service必须通过__init__注入 Repo”、“不能跨模块调Repo”,这其实就是手动实现了IoC容器的依赖管理规则

什么是AOP层?

  • AOP即面向切面编程(Aspect-Oriented Programming)
  • 在编程中,有些功能会散落在各处,与核心业务逻辑无关,但又必不可少。比如日志记录、事务管理、权限校验。这些被称为横切关注点(Cross-cutting Concerns)
  • AOP的想法是,把这些横切的代码从核心业务中剥离出来,定义为一个“切面”。然后通过某种方式,在不修改核心业务代码的情况下,把这个切面的逻辑“织入”到它该去的地方

说说你对控制反转(IoC)和依赖注入(DI)的理解

  • 控制反转(IoC)是一种设计原则,核心思想是:“不要来找我,我会来找你”。传统编程中,对象自己控制依赖的创建和获取(自己new或调用工厂);IoC将这一控制权从对象内部转移到了外部容器或组装点
  • 依赖注入(DI)是实现IoC最主流的方式。它通过构造器、Setter或接口,将依赖被动地注入到对象中,而不是让对象主动去查找或创建依赖
  • IoC是“谁来控制”的问题(答:外部控制),DI是“怎么给”的问题(答:注入进去)

构造器注入和属性注入、方法注入有什么区别?为什么构造器注入是首选

  • | 注入方式 | 实现 | 优点 | 缺点 |
  • |:—-:|:—-:|:—-:|:—-:|
  • | 构造器注入 | 通过__init__参数传入 | 依赖强制完整、对象创建后即处于可用状态、不可变、易于发现依赖 | 依赖多时构造器参数过长(提示类职责过多)|
  • | 属性注入 | 通过setter或直接赋值属性 | 灵活 | 对象可能处于中间态、依赖不完整、隐藏依赖 |
  • | 方法注入 | 通过具体方法参数传入 | 依赖限定在方法作用域 | 仅限于该方法的局部依赖 |
  • 为什么首选构造器注入:
  • 对象从诞生的那一刻起就是完整的、可用的,没有“半成品”状态
  • 依赖以参数形式公之于众,形成类的自文档化接口
  • 迫使开发者思考类的职责边界——当构造器参数过多,说明类该拆分了

依赖注入解决了什么问题?不用的系统长什么样?

  • 不用 DI 的系统(即“自行获取”模式):
    class Service:
    def __init__(self):
    self.db = get_db() # 硬编码依赖
    self.repo = Repository(self.db)
  • 这种设计的问题在于:
  • 强耦合:类和具体的get_db函数、Repository类绑定,无法替换
  • 不可测试:单元测试必须连真实数据库,变成集成测试;或者用猴子补丁污染全局状态
  • 隐式依赖:看__init__签名无法知道类依赖了什么,必须读完实现代码
  • 违反开闭原则:换个数据源就要改类内部代码
  • 用了 DI 的系统:
    class Service:
    def __init__(self, db_session, repository):
    self.db = db_session
    self.repo = repository
  • DI的优势在于:
  • 解耦:类只依赖抽象接口,不关心具体实现
  • 可测试:Mock 注入,完全隔离,真正单元测试
  • 显式:构造器就是类的“依赖清单”
  • 灵活:换个实现只需在组装点替换,类内部不动

DI容器(如Spring IoC、Guice)做了什么?没有容器能实现DI吗?

  • DI容器不是DI本身,而是DI的自动化工具。
  • DI的核心是“控制权转移”,手工就能做到——在程序入口创建所有依赖,通过构造器传进去。这就是纯手工DI
  • DI容器做的是:
  • 自动装配:根据类型或名称自动找到依赖并注入
  • 生命周期管理:管理对象的创建作用域(单例、请求作用域、原型等)
  • 声明式配置:通过注解(@Autowired)或XML声明依赖关系
  • DI是一种思想,DI容器是一种工具

更深入的八股问题(定义不止于junior)

除了流式读取,还有哪些处理海量数据的策略?

  • 分页查询、基于时间戳的增量同步、将数据处理任务从Web服务转移到异步消息队列等

流式读取和分页读取有什么区别?

  • 流式读取:依赖一个长事务和数据库游标来保证数据完整性,适合一次性处理完整的历史数据快照
  • 分页读取:通过LIMIT / OFFSET把数据分成独立的“页”,每次都发起一个新查询。每个查询可能看到不同的数据快照(比如有新数据插入时,后续页面可能出现重复或错位),不适合严格的数据快照,但更适合API接口那种无状态的场景

并发策略

悲观锁

  • 假设冲突一定会发生,所以在动手前,先把资源锁住,不让别人碰
  • [核心机制]
  • 数据库悲观锁 (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层捕获后透传或转换为业务异常再抛出

Service层遍地if和try-except,代码看起来很臃肿,这正常吗?

  • 先做出肯定的回答:“这是刻意为之的架构选择”
  • 首先,这是业务规则的显式化(遍地if)。大多数框架用注解或配置来隐藏校验逻辑——@NotNull@Min@Valid。这些隐式机制让代码看起来干净,但代价是:规则分散在注解、配置、数据库约束里,难以在一个地方看到完整的”这个字段到底有哪些约束”。而我们选择把每一条业务规则都写成一个显式的if语句,集中在一个私有校验方法里
  • 代价是代码看起来不那么优雅。收益是:
  • 可发现性:想知道某个操作有哪些校验规则?打开_validate_xxx_request,一目了然
  • 可追溯性:每条规则都是代码,Git能告诉你谁、什么时候、为什么加了它
  • 可修改性:改一条规则,只需改一个if,运行独立测试即可验证,不担心副作用
  • 其次,这是事务控制的显式化(遍地try-except)。框架用@Transactional注解自动管理事务
  • 而我们选择手写try-except-commit-rollback,因此我们需要/能够精确控制三种失败的不同处理:
  • 业务异常(如并发冲突)→ 透传,不回滚
  • 未知异常(如数据库宕机)→ 回滚 + 记ERROR日志
  • 校验失败 → 在try之前就Fail Fast
  • 以上两点,最终都指向同一个目标:追求系统的可维护性和可控性

为什么手动管理事务,比@Transactional更可控?

  • 因为“可控性”的核心,在于对失败模式的精确裁决权
  • @Transactional这类框架注解,本质上是一种“隐式契约”。开发者只需声明“我要事务”,框架就自动帮你开启、提交或回滚。这看起来很“省心”,但代价是开发者交出了最关键的裁决权:当异常发生时,是回滚还是提交?框架会自动做出决定,而这个决定不一定正确
  • 而手动管理事务,就是把这种裁决权从框架手中拿回来,亲自掌握。它带来的可控性体现在以下三个层面
  1. 对三种失败路径的精确控制。一个操作可能遇到三种完全不同的失败,每种失败的处理策略截然不同,如:
  • 业务异常(如库存不足、版本冲突):这是预期的、正常的业务结果。事务本身是成功的,数据是干净的,不应回滚。我们应该透传这个异常给上层,让调用方决定如何处理(如重试)。
  • 未知异常(如数据库连接中断):这是意料之外的技术故障。当前事务的状态是未知的、可能已被污染的,必须立即回滚,以确保数据一致性
  • 入参校验失败:这是在业务逻辑执行前就发现的问题,事务根本不应该被开启
  • 而在spring boot中,@Transactional将后两种都归类为“异常→回滚”,而第一种需要特殊配置才能不回滚。它模糊了这三者的边界。而手动try-except的结构,能将这三种路径区分得清清楚楚,由此,即可以解读为可控性:我们不是在处理异常,我们是在定义不同失败模式下的事务命运
  1. 对事务边界的清晰定义
  • @Transactional的事务边界,就是方法执行的边界。但有时候,你可能只想让数据库操作部分在事务里,而把一些耗时的非数据库操作(如外部API调用)放在事务外。手写事务让你能自由地定义事务开始和提交的精确位置,避免不必要地锁住数据库资源
  1. 符合“系统行为白盒化”的架构原则
  • ModM这个项目,致力于让系统的每一个关键行为都变得可读、可调试、可审计。手写事务管理,就是将“事务”这个最关键的、影响数据一致性的行为,从“黑盒”变为“白盒”。任何开发者看到这段代码,都能立即理解:在什么情况下会成功提交,在什么情况下会失败回滚。没有任何“魔法”隐藏在注解背后

项目中“遍地 try-except”,正是在手动模拟Spring AOP代理自动织入的事务管理代码

  • 本项目中,每个Service方法的try-except结构都是显式的事务边界定义
  • 集中的校验方法是在手工实现AOP的校验切面
  • __init__里的依赖注入是在手动模拟IoC容器
  • 为了便于更进一步理解,可参照Spring中,如何将IoC和AOP思想紧密结合:
  • IoC负责“找到”:Spring容器管理的所有Bean,它都会扫描,看谁身上有@Transactional注解。
  • AOP负责“织入”:Spring会为这些Bean 生成一个代理。当你调用一个带@Transactional的方法时,实际执行的是代理里的逻辑。代理的代码会在你的方法执行前后,自动执行“开启事务”、“提交事务”或“回滚事务”的代码

你在项目中是怎么实践 DI 的?带来了什么实际收益?

  • 实践方式:
  • 项目采用纯手工构造器注入,无DI框架
  • 所有Service类在__init__中声明依赖:def init(self, db_session, repo_a, repo_b)
  • 跨模块依赖同样通过构造器注入(如InventoryService持有ProductRepo)
  • 依赖的装配由测试代码(当前)或 API入口函数(未来)集中完成
  • 实际收益:
  • 测试隔离:Mock(spec=SomeRepo)后注入,单元测试完全不碰数据库,秒级运行
  • 依赖可见:看构造器就知道一个Service承担了多少职责——参数超过3个说明该类可能职责过多
  • 架构约束:任何试图在类内部自己get_db()的行为,都会立刻被测试发现(因为无法Mock),形成自动化架构审计
  • 拆分准备:每个模块只依赖注入进来的接口,未来拆微服务时,只需把Repo 替换为HTTP Client,Service内部零改动

反问环节问题收集

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

探知团队的生存状态

  • 通过“系统演化”探知战略地位
  • 核心话术: “能简单介绍一下你们团队负责的系统,未来一年的演化路线图吗?比如有什么重大的重构、拆分,或者新模块计划?”
  • [有清晰规划] 回答中包含明确的里程碑、技术选型讨论、性能指标——说明团队有资源、有规划,处于扩张/稳定期
  • [只有“维护”] 只会说“维持现状”、“修bug为主”——系统处于维护期,业务停滞,团队可能面临被边缘化的风险
  • [主动提及“技术债治理”、“模块拆分”] 这是最强信号。说明团队不仅在活着,还在为长期可维护性投资,有足够话语权和生存安全感
  • 这是最强信号。说明团队不仅在活着,还在为长期可维护性投资,有足够话语权和生存安全感
  • 通过“协作模式”探知组织健康度
  • 核心话术: “能举一个你最近负责的需求例子,讲讲从需求提出到上线的全流程,你主要和哪些角色协作,代码审查怎么做的吗?”
  • [流程顺畅、分工清晰] 有明确的产品、开发、测试、CR流程,他清楚自己的工作边界——健康的团队
  • [“我一个人全包了”或“需求直接口头说”] 要么是极度缺人的小团队(风险高),要么是混乱的作坊式团队(成长性差)
  • [频繁提及“跨团队沟通”、“等待上游接口”] 系统架构的耦合反映了组织的耦合。大量时间花在协调上,说明是成熟期/内耗期的大厂部门,效率低但相对稳定
  • 通过“技术债治理”探知长期主义
  • 核心话术: “团队对技术债是什么态度?最近一次偿还技术债或者重构是什么时候,能具体讲讲吗?”
  • [有具体案例和成果] 能说出具体技术债(如“我们把那个古老的XXX模块重写了”),说明团队有工程纪律和长期主义,存活率极高
  • [“没时间”、“以后再说] 团队疲于奔命,没有资源和精力做长期投资。系统熵增不可逆,团队处于生存边缘
  • [面试官主动抱怨“代码太烂,但没人敢动”] 系统已经固化,架构恐惧蔓延,团队处于衰落期。这是最危险的信号
  • 通过“可观测性”探知工程文化
  • 核心话术: “线上出问题时,你们通常怎么定位?有统一的日志规范和全链路追踪吗?”
  • [有完善体系] 能说出日志平台、Metrics监控、Trace系统——工程文化成熟,团队有技术话语权
  • [“主要靠经验”、“看服务器日志”] 工程纪律缺失,团队大概率处于低成熟度阶段,存活率取决于业务刚需程度而非技术质量
  • 反问环节的“架构问题”
  • 核心话术: “我在做我的个人项目时,特别关注系统架构的可演化和纪律性,甚至为此写了一份工程规范。我很好奇,咱们团队在系统设计上,有没有一些不成文的、但在CR中一定会被约束的‘工程原则’?”
  • [能说出一两条具体原则] 即使没有CONVENTIONS.md这样的文档,但团队有不成文的共识——说明有工程文化沉淀
  • [“我们主要看功能对不对”] 工程纪律缺失,技术债会在暗处堆积
  • [面试官反过来对你的项目感兴趣] 说明ta认可你的思维方式,暗示团队需要这种带来自律文化的人

建议深入的部分

  • Service层写方法的测试覆盖率:把reserve_stock的所有成功/失败路径(库存不足、版本冲突、跨域异常)用测试覆盖全。这是“工程纪律”的最佳体现
  • API层(哪怕是一个简单的Mock):用一个FastAPI或Flask端点把Service方法暴露出去,证明整个三层架构能串联起来。即使只是一个/inventory/reserve 的POST接口
  • 一个端到端的集成测试:从API到数据库再回数据库,验证一次完整的reserve流程。这会成为你项目README里的亮点
  • README活项目文档:用你的域名博客,写一篇“ModM项目架构演进史”,把从V1到V2的决策过程讲清楚。初级开发者能写出这种文章,面试官会直接对你另眼相看