一、大营销架构
1、DDD架构
在big-market项目中,采用了六层架构设计,每一层都有其特定的职责和作用。下面我来详细介绍一下这六层架构:
1. 应用层 (big-market-app)
应用层是整个系统的入口,负责接收和处理用户请求,协调各个领域服务的调用。
- 主要包含测试用例和应用服务
- 负责组合和编排领域服务
- 不包含业务逻辑,只负责流程控制
2. 领域层 (big-market-domain)
领域层是核心业务逻辑所在的地方,包含了业务实体、值对象、领域服务等。
- 包含业务实体(如StrategyEntity)
- 包含值对象(如RuleLogicCheckTypeVO)
- 包含领域服务(如策略服务、活动服务)
- 实现核心业务逻辑和规则
3. 基础设施层 (big-market-infrastructure)
基础设施层提供技术支持,如数据库访问、缓存、消息队列等。
- 实现领域层定义的仓储接口
- 提供数据持久化服务
- 包含DAO接口和PO对象
- 处理与外部系统的交互
4. 接口层 (big-market-api)
接口层定义了系统对外提供的服务接口,是系统与外部交互的契约。
- 定义DTO对象
- 定义服务接口
- 提供API文档
- 确保接口的稳定性和兼容性
5. 触发器层 (big-market-trigger)
触发器层负责处理外部事件和调度任务。
- 包含HTTP接口控制器
- 包含消息监听器
- 包含定时任务
- 负责将外部请求转发到应用服务
6. 类型层 (big-market-types)
类型层定义了系统中使用的通用类型和常量。
- 定义枚举类型
- 定义常量
- 定义通用异常
- 定义通用响应对象
2、各种图
1. 抽奖流程图
2. 库表设计图
3、聚合对象,实体对象,值对象
在面向对象设计和领域驱动设计(DDD)中,聚合对象、实体对象和值对象是三种核心概念,主要用于区分不同类型的对象及其在设计中的作用。以下是它们的核心区别及示例:
1. 实体对象(Entity)
-
核心特征:
- 唯一标识:通过业务规则赋予的唯一标识符(如 ID)区分不同实例,即使属性相同,只要 ID 不同就是不同的实体。
- 可变性:允许修改自身属性(状态),生命周期由业务逻辑控制。
- 独立存在:通常对应数据库表中的一行记录。
-
典型场景:
- 用户账户(
User)、订单(Order)、商品(Product)。
- 用户账户(
-
示例代码:
public class User { private Long id; // 唯一标识 private String name; private String email; // Getters, setters, 业务方法 }
2. 值对象(Value Object)
-
核心特征:
- 无唯一标识:仅通过属性值的组合定义其本质,不可变性(Immutable)。
- 不可共享:修改需新建实例,避免副作用。
- 无生命周期:不直接关联业务状态,常作为实体属性或方法参数。
-
典型场景:
- 地址(
Address)、货币金额(Money)、时间戳(DateTime)。
- 地址(
-
示例代码:
public final class Money { private final BigDecimal amount; private final String currency; public Money(BigDecimal amount, String currency) { this.amount = amount; this.currency = currency; } // 只读方法,无 setter }
3. 聚合对象(Aggregate)
-
核心特征:
- 整体-部分关系:表示一组相关对象的集合,称为“聚合”,由聚合根(Root Entity)统一管理。
- 生命周期依赖:子对象(如订单项)无法独立存在,其生命周期受父对象(如订单)控制。
- 防御性编程:通过封装保护内部一致性,禁止外部直接访问子对象。
-
典型场景:
- 订单与订单项(
Order和OrderItem)、文档与段落(Document和Paragraph)。
- 订单与订单项(
-
示例代码:
public class Order { private Long id; // 聚合根标识 private List<OrderItem> items = new ArrayList<>(); public void addItem(OrderItem item) { items.add(item); // 通过聚合根控制添加 } // 隐藏 items 的直接访问,提供受控接口 } public class OrderItem { private String productCode; private int quantity; // 无独立 ID,依赖 Order 存在 }
4. 四者对比表
| 特性 | 实体对象 | 值对象 | 聚合对象 |
|---|---|---|---|
| 唯一标识 | ✅(如 ID) | ❌ | ❌(依赖聚合根) |
| 可变性 | ✅ | ❌(不可变) | ✅(聚合根可管理状态) |
| 生命周期 | 独立 | 无 | 依赖父对象 |
| 关系模式 | 独立个体 | 无 | 整体-部分(聚合) |
| 数据库映射 | 表(Table) | 无(通常不存储) | 表(聚合根表 + 子表或嵌套) |
| 典型用途 | 业务核心实体 | 描述性数据 | 复杂业务场景的组合结构 |
4、战略、战术、战役
首先 DDD 是一种软件设计方法,Domain-driven design (DDD) is a major software design approach. (opens new window)来自维基百科。软件设计方法涵盖了;范式、模型、框架、方法论,主要活动包括建模、测试、工程、开发、部署、维护。来自维基百科的软件设计 (opens new window)涵盖信息介绍。
在 DDD 领域驱动设计中,常提到战略、战术,和一少部分会讲到战役。这3个词主要讲的是不同的开发阶段所需要完成的事项;
- 战略 - 建模;领域划分、界限上下文、核心领域
- 战术 - 架构;工程结构、领域对象、领域服务、领域事件
- 战役 - 编码;设计原则、设计模式
DDD 的战略、战术和战役设计相辅相成,战略提供系统的建模作为宏观指导,战术下面有N个战役,两者则关注具体的实现和编码落地。
在维基百科中有不少 DDD 非常好的资料,其中一个是关于事件风暴的,讲解了执行战略设计中风暴模型的步骤。
有了这基础认知,接下来我们通过《大营销项目》从需求到设计,一步步了解系统的领域驱动设计。
5、产品需求
1. 产品诉求
如图,是一个复杂的营销抽奖场景玩法需求,涵盖了;活动配置、签到&奖励、活动账户、抽奖策略「责任链+规则树」、库存扣减、抽奖满N次后阶梯抽奖等。面对这样的复杂系统,非常适合使用 DDD 落地。
分析需求;
- 整体概率相加,总和为1或者分值计算,概率范围千分位
- 抽奖为免费抽奖次数 + 用户消耗个人积分抽奖
- 抽奖活动可给用户分配可抽奖次数,通过点击签到发放
- 活动延伸配置用户库存消耗管理,单独提供表配置各类库存 用户可用总库存、用户可用日库存
- 部分抽奖规则,需要抽奖n次后解锁,才能有机会抽取
- 抽奖完成增加(运气值/积分值/抽奖次数)记录,让用户获得奖品。
- 奖品对接,自身的积分、内部系统的奖品
- 随机积分,发给你积分。
- 黑名单用户抽奖,则给予固定的奖品。
2. 业务流程
依照于产品需求,在产品的 PRD 文档中还会绘制出业务流程图。产品的流程图会比较粗一些,研发后期需要根据产品的 PRD 文档做具体的设计。
- 产品经理会详细的介绍整个系统的功能流程和需要对接接口文档。
- 以上就是以用户旅程为维度,从点击签到获得活动账户额度,再到一些列抽奖、抽奖策略、中奖结果和奖品发放的流程。
6、系统架构
如果首次承接的是一个新的系统,还需要对系统进行架构设计,是单体架构还是分布式架构,以及所要用到的技术栈。最好在提供好相关的落地案例和DDD脚手架
1. 分布式架构
2. 分布式技术
7、战略设计
1. 用例图
根据业务需求画系统用例图;
- 用例图(英语:use case diagram)是用户与系统交互的最简表示形式,展现了用户和与他相关的用例之间的关系。通过用例图,人们可以获知系统不同种类的用户和用例。用例图也经常和其他图表配合使用。
- 用例图,也可以等同于是用户故事(英语:User story)(软件开发和项目管理中的常用术语),主旨是以日常语言或商务用语撰写句子,是一段简单的功能表述。以客户或使用者的观点撰写下有价值的功能、引导、框架来与使用者进行互动,进而推动工作进程。可以被认为是一种规格文件,但更精确而言,它代表客户的需求与方向。以该用户故事来反应对象在组织内的其工作职责、范围、需要进行的任务等。用户故事在敏捷开发方法中用来定义系统需要提供的功能和实现需求管理。
- 尽管用例本身会涉及大量细节和各种可能性,用例图却能提纲挈领地让人了解系统概况。它为“系统做什么”提供了简化了的图形表示,因此被誉为“搭建系统的蓝图”。
2. 事件风暴定义
在使用 DDD 的标准对系统建模前,一堆人要先了解 DDD 的操作手段,这样才能让产品、研发、测试、运营等了解业务的伙伴,都能在同一个语言下完成系统建模。
- 蓝色 - 决策命令,是用户发起的行为动作,如;开始签到、开始抽奖、查看额度等。
- 黄色 - 领域事件,过去时态描述。如;签到完成、抽奖完成、奖品发放完成。它所阐述的都是这个领域要完成的终态。
- 粉色 - 外部系统,如你的系统需要调用外部的接口完成流程。
- 红色 - 业务流程,用于串联决策命令到领域事件,所实现的业务流程。一些简单的场景则直接有决策命令到领域事件就可以了。
- 绿色 - 只读模型,做一些读取数据的动作,没有写库的操作。
- 棕色 - 领域对象,每个决策命令的发起,都是含有一个对应的领域对象。
综上,左下角的示意图。就是一个用户,通过一个策略命令,使用领域对象,通过业务流程,完成2个领域事件,调用1次外部接口个过程。我们在整个 DDD 建模过程中,就是在寻找这些节点。
3. 寻找领域事件
接下来,大量的时间,都是在挖掘领域事件。这个过程就是一堆人头脑风暴的过程,避免错失流程节点。
- 根据产品 PRD 文档,一起开会梳理有哪些领域事件。其实大多数领域事件一个人都可以想到,只是有些部分小的场景和将来可能产生的事件不一定覆盖全。所以要通过产品、测试、以及团队的架构师,一起讨论。
- 像是整个大营销的抽奖会包括如图所列举的事件。在列举这个阶段,你用在乎格式。也可以是每个人准备好黄色便签纸,想到一个就贴到黑板上一个,只是穷举完成。—— 实际做DDD中,也是这样用便签纸贴黑板,所以用不同的颜色做区分。
4. 识别领域角色和对象
在确定了领域事件以后,接下来要做的就是通过决策命令串联领域事件,并填充上所需要的领域对象。这块的操作,新手可以分开处理,如先给领域事件添加决策命令、执行用户和领域对象,最后在串联流程。就像 事件风暴定义 中的示意一样。
- 首先,通过用户的行为动作,也就是决策命令,串联到对应的领域事件上。并对复杂的流程提供出红色的业务流程。
- 之后,为决策命令添加领域对象,每一个领域在整个流程中都起到了至关重要的作用。
5. 划分领域边界
有了识别出来的领域角色的流程,就可以非常容易的划分出领域边界了。先在事件风暴图上圈出领域边界,之后在单独提供领域划分。
5.1 圈出领域
5.2 领域边界
- 到这步咱们就可以获得整个项目中 DDD 的领域边界划分了。之后再往下就是具体的每个领域对象的详细设计和流程设计。
6. 研发详细设计
6.1 实体对象
- 你需要对每一个领域对象进行字段的详细设计。并划分出它们的上下文关系。一般在公司中,这部分设计完成,其他人也能对照你的设计进行代码开发。
6.2 流程设计
- 流程设计,就是更详细的设计了。每一步要调用到哪个系统,哪个接口,要执行什么动作就全部都有了。
8、工程实现
DDD 的战略设计做完,划分出领域边界以后。接下来就是要执行战术和战役了。也就是在工程中做编码实现。但一定要懂得设计原则和设计模式,否则写不出好的代码的。
- 工程实现,就是在确定的框架结构中编码。可以是洋葱架构、整洁架构、菱形架构等等。这部分内容的可以通过实战项目来锻炼,获得编码技巧。
二、小林-Redis事务面试题
1、如何实现redis 原子性?
redis 执行一条命令的时候是具备原子性的,因为 redis 执行命令的时候是单线程来处理的,不存在多线程安全的问题。
如果要保证 2 条命令的原子性的话,可以考虑用 lua 脚本,将多个操作写到一个 Lua 脚本中,Redis 会把整个 Lua 脚本作为一个整体执行,在执行的过程中不会被其他命令打断,从而保证了 Lua 脚本中操作的原子性。
比如说,在用 redis 实现分布式锁的场景下,解锁期间涉及 2 个操作,分别是先判断锁是不是自己的,是自己的才能删除锁,为了保证这 2 个操作的原子性,会通过 lua 脚本来保证原子性。
// 释放锁时,先比较 unique_value 是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
2、除了lua有没有什么也能保证redis的原子性?
redis 事务也可以保证多个操作的原子性。
如果 redis 事务正常执行,没有发生任何错误,那么使用 MULTI 和 EXEC 配合使用,就可以保证多个操作都完成。
但是,如果事务执行发生错误了,就没办法保证原子性了。比如说 2 个操作,第一个操作执行成果了,但是第二个操作执行的时候,命令出错了,那事务并不会回滚,因为Redis 中并没有提供回滚机制。
举个小例子。事务中的 LPOP 命令对 String 类型数据进行操作,入队时没有报错,但是,在 EXEC 执行时报错了。LPOP 命令本身没有执行成功,但是事务中的 DECR 命令却成功执行了。
#开启事务
127.0.0.1:6379> MULTI
OK
#发送事务中的第一个操作,LPOP命令操作的数据类型不匹配,此时并不报错
127.0.0.1:6379> LPOP a:stock
QUEUED
#发送事务中的第二个操作
127.0.0.1:6379> DECR b:stock
QUEUED
#实际执行事务,事务第一个操作执行报错
127.0.0.1:6379> EXEC
1) (error) WRONGTYPE Operation against a key holding the wrong kind of value
2) (integer) 8
因此,Redis 对事务原子性属性的保证情况:
- Redis 事务正常执行,可以保证原子性;
- Redis 事务执行中某一个操作执行失败,不保证原子性;
三、小林-Redis日志,持久化面试题
1、Redis有哪2种持久化方式?分别的优缺点是什么?
Redis 的读写操作都是在内存中,所以 Redis 性能才会高,但是当 Redis 重启后,内存中的数据就会丢失,那为了保证内存中的数据不会丢失,Redis 实现了数据持久化的机制,这个机制会把数据存储到磁盘,这样在 Redis 重启就能够从磁盘中恢复原有的数据。Redis 共有三种数据持久化的方式:
- AOF 日志:每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里;
- RDB 快照:将某一时刻的内存数据,以二进制的方式写入磁盘;
AOF 日志是如何实现的?
Redis 在执行完一条写操作命令后,就会把该命令以追加的方式写入到一个文件里,然后 Redis 重启时,会读取该文件记录的命令,然后逐一执行命令的方式来进行数据恢复。
我这里以「set name xiaolin」命令作为例子,Redis 执行了这条命令后,记录在 AOF 日志里的内容如下图:
Redis 提供了 3 种写回硬盘的策略, 在 Redis.conf 配置文件中的 appendfsync 配置项可以有以下 3 种参数可填:
- Always,这个单词的意思是「总是」,所以它的意思是每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘;
- Everysec,这个单词的意思是「每秒」,所以它的意思是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘;
- No,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。
我也把这 3 个写回策略的优缺点总结成了一张表格:
RDB 快照是如何实现的呢?
因为 AOF 日志记录的是操作命令,不是实际的数据,所以用 AOF 方法做故障恢复时,需要全量把日志都执行一遍,一旦 AOF 日志非常多,势必会造成 Redis 的恢复操作缓慢。为了解决这个问题,Redis 增加了 RDB 快照。
所谓的快照,就是记录某一个瞬间东西,比如当我们给风景拍照时,那一个瞬间的画面和信息就记录到了一张照片。所以,RDB 快照就是记录某一个瞬间的内存数据,记录的是实际数据,而 AOF 文件记录的是命令操作的日志,而不是实际的数据。因此在 Redis 恢复数据时, RDB 恢复数据的效率会比 AOF 高些,因为直接将 RDB 文件读入内存就可以,不需要像 AOF 那样还需要额外执行操作命令的步骤才能恢复数据。
Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave,他们的区别就在于是否在「主线程」里执行:
- 执行了 save 命令,就会在主线程生成 RDB 文件,由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长,会阻塞主线程;
- 执行了 bgsave 命令,会创建一个子进程来生成 RDB 文件,这样可以避免主线程的阻塞;
AOF和RDB优缺点
AOF:
- 优点:首先,AOF提供了更好的数据安全性,因为它默认每接收到一个写命令就会追加到文件末尾。即使Redis服务器宕机,也只会丢失最后一次写入前的数据。其次,AOF支持多种同步策略(如everysec、always等),可以根据需要调整数据安全性和性能之间的平衡。同时,AOF文件在Redis启动时可以通过重写机制优化,减少文件体积,加快恢复速度。并且,即使文件发生损坏,AOF还提供了redis-check-aof工具来修复损坏的文件。
- 缺点:因为记录了每一个写操作,所以AOF文件通常比RDB文件更大,消耗更多的磁盘空间。并且,频繁的磁盘IO操作(尤其是同步策略设置为always时)可能会对Redis的写入性能造成一定影响。而且,当问个文件体积过大时,AOF会进行重写操作,AOF如果没有开启AOF重写或者重写频率较低,恢复过程可能较慢,因为它需要重放所有的操作命令。
RDB:
- 优点: RDB通过快照的形式保存某一时刻的数据状态,文件体积小,备份和恢复的速度非常快。并且,RDB是在主线程之外通过fork子进程来进行的,不会阻塞服务器处理命令请求,对Redis服务的性能影响较小。最后,由于是定期快照,RDB文件通常比AOF文件小得多。
- 缺点: RDB方式在两次快照之间,如果Redis服务器发生故障,这段时间的数据将会丢失。并且,如果在RDB创建快照到恢复期间有写操作,恢复后的数据可能与故障前的数据不完全一致
四、小林-Redis缓存淘汰和过期删除面试题
1、过期删除策略和内存淘汰策略有什么区别?
区别:
- 内存淘汰策略是在内存满了的时候,redis 会触发内存淘汰策略,来淘汰一些不必要的内存资源,以腾出空间,来保存新的内容
- 过期键删除策略是将已过期的键值对进行删除,Redis 采用的删除策略是惰性删除+定期删除。
2、介绍一下Redis 内存淘汰策略
在 32 位操作系统中,maxmemory 的默认值是 3G,因为 32 位的机器最大只支持 4GB 的内存,而系统本身就需要一定的内存资源来支持运行,所以 32 位操作系统限制最大 3 GB 的可用内存是非常合理的,这样可以避免因为内存不足而导致 Redis 实例崩溃。
Redis 内存淘汰策略共有八种,这八种策略大体分为「不进行数据淘汰」和「进行数据淘汰」两类策略。
1、不进行数据淘汰的策略:
- noeviction(Redis3.0之后,默认的内存淘汰策略) :它表示当运行内存超过最大设置内存时,不淘汰任何数据,这时如果有新的数据写入,会报错通知禁止写入,不淘汰任何数据,但是如果没用数据写入的话,只是单纯的查询或者删除操作的话,还是可以正常工作。
2、进行数据淘汰的策略:
针对「进行数据淘汰」这一类策略,又可以细分为「在设置了过期时间的数据中进行淘汰」和「在所有数据范围内进行淘汰」这两类策略。
- 在设置了过期时间的数据中进行淘汰:
- volatile-random:随机淘汰设置了过期时间的任意键值;
- volatile-ttl:优先淘汰更早过期的键值。
- volatile-lru(Redis3.0 之前,默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值;
- volatile-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值;
- 在所有数据范围内进行淘汰:
- allkeys-random:随机淘汰任意键值;
- allkeys-lru:淘汰整个键值中最久未使用的键值;
- allkeys-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值。
3、介绍一下Redis过期删除策略
Redis 选择「惰性删除+定期删除」这两种策略配和使用,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。
Redis 的惰性删除策略由 db.c 文件中的 expireIfNeeded 函数实现,代码如下:
int expireIfNeeded(redisDb *db, robj *key) {
// 判断 key 是否过期
if (!keyIsExpired(db,key)) return 0;
....
/* 删除过期键 */
....
// 如果 server.lazyfree_lazy_expire 为 1 表示异步删除,反之同步删除;
return server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) :
dbSyncDelete(db,key);
}
Redis 在访问或者修改 key 之前,都会调用 expireIfNeeded 函数对其进行检查,检查 key 是否过期:
- 如果过期,则删除该 key,至于选择异步删除,还是选择同步删除,根据 lazyfree_lazy_expire 参数配置决定(Redis 4.0版本开始提供参数),然后返回 null 客户端;
- 如果没有过期,不做任何处理,然后返回正常的键值对给客户端;
惰性删除的流程图如下:
Redis 的定期删除是每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。
1、这个间隔检查的时间是多长呢?
在 Redis 中,默认每秒进行 10 次过期检查一次数据库,此配置可通过 Redis 的配置文件 redis.conf 进行配置,配置键为 hz 它的默认值是 hz 10。特别强调下,每次检查数据库并不是遍历过期字典中的所有 key,而是从数据库中随机抽取一定数量的 key 进行过期检查。
2、随机抽查的数量是多少呢?
我查了下源码,定期删除的实现在 expire.c 文件下的 activeExpireCycle 函数中,其中随机抽查的数量由 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 定义的,它是写死在代码中的,数值是 20。也就是说,数据库每轮抽查时,会随机选择 20 个 key 判断是否过期。接下来,详细说说 Redis 的定期删除的流程:
- 从过期字典中随机抽取 20 个 key;
- 检查这 20 个 key 是否过期,并删除已过期的 key;
- 如果本轮检查的已过期 key 的数量,超过 5 个(20/4),也就是「已过期 key 的数量」占比「随机抽取 key 的数量」大于 25%,则继续重复步骤 1;如果已过期的 key 比例小于 25%,则停止继续删除过期 key,然后等待下一轮再检查。
可以看到,定期删除是一个循环的流程。那 Redis 为了保证定期删除不会出现循环过度,导致线程卡死现象,为此增加了定期删除循环流程的时间上限,默认不会超过 25ms。针对定期删除的流程,我写了个伪代码:
do {
//已过期的数量
expired = 0;
//随机抽取的数量
num = 20;
while (num--) {
//1. 从过期字典中随机抽取 1 个 key
//2. 判断该 key 是否过期,如果已过期则进行删除,同时对 expired++
}
// 超过时间限制则退出
if (timelimit_exit) return;
/* 如果本轮检查的已过期 key 的数量,超过 25%,则继续随机抽查,否则退出本轮检查 */
} while (expired > 20/4);
定期删除的流程如下:
4、Redis的缓存失效会不会立即删除?
不会,Redis 的过期删除策略是选择「惰性删除+定期删除」这两种策略配和使用。
- 惰性删除策略的做法是,不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key。
- 定期删除策略的做法是,每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。
5、那为什么我不过期立即删除?
在过期 key 比较多的情况下,删除过期 key 可能会占用相当一部分 CPU 时间,在内存不紧张但 CPU 时间紧张的情况下,将 CPU 时间用于删除和当前任务无关的过期键上,无疑会对服务器的响应时间和吞吐量造成影响。所以,定时删除策略对 CPU 不友好。