读完这篇,你能做什么
能画出一个请求经过接口、数据库和 Redis 的数据流,并标出事实来源。
学习理由、准备要求与知识点
为什么学它
先确定业务事实和派生数据的边界,才能理解点评项目各模块为什么选择不同存储。
先读这些内容
知识点清单
- 用户与商户
- 评价与关注
- 券与订单
- 主外键关系
MySQL业务事实- Redis 派生数据
- 登录态
- 接口数据流
运行前先确认以点评应用的通用教学设计说明业务与数据关系,不对应已检查的黑马点评源码。概念使用 MySQL 8.4 与 Redis 7.4,Java 源码固定 Spring Framework 6.2.11;完整练习为 Java 17。
01先弄懂它是什么
学习点评项目时,先沿着用户操作走一遍:登录、查商户、看探店、关注作者、领券、查订单。每一步写清输入是什么、谁在操作、要检查什么、哪些数据会变化,以及返回什么。例如查商户主要读数据,发布笔记会新增内容,买券则同时涉及用户、库存和订单唯一性。这样才能知道每个技术组件在解决什么问题。
再把用户、商户、笔记、关注关系、优惠券和订单分别列出来。笔记有作者并关联商户,券属于商户,订单关联买券用户和券;关注有方向,A 关注 B 不等于 B 关注 A。编号用来识别对象,字段记录状态,关系约束它们怎样联系。昵称重复可能允许,订单编号重复通常必须拒绝,要按含义定规则。
还要分清哪份数据记录业务发生了什么,哪份只是为了方便查询而算出来。数据库里的有效订单记录一次购买;商户订单数可以从订单汇总得到,Redis 详情、排名或地理索引则是整理出的查询数据,常称为投影。登录态不同,它还有自己的有效期,不能读一次用户表就恢复成原来的会话。只有原始事实、转换规则和需要恢复的范围都明确,才能说某份查询数据可以重建。
文中这几个词,先认一认
- 业务事实
- 业务已经发生、需要可靠保留的信息,例如订单金额、创建时间和有效状态。
- 查询投影
- 把原始事实整理成方便某种查询的数据,例如由订单汇总商户订单数。
- 不变量
- 每次合法操作后都必须成立的规则,例如一笔订单必须有对应的购买用户。
02看看它怎么工作
先为一条下单链写关系图:会话恢复用户身份,业务层校验券状态,原子条件限制库存,订单记录保存购买者与成交信息,查询层返回最小 DTO。请求中的 userId 不应覆盖服务端确认的当前用户。订单列表除了登录检查,还需按当前用户限制结果;认证证明是谁,授权决定可以操作哪条记录,这两个边界都应出现在图中。
给每类数据记录唯一键、写入者、生命周期和恢复入口。用户手机号的唯一规则取决于业务约定,用户加优惠券的组合唯一约束适用于规定一人一单的教学场景。关系存在可以由外键或等价业务校验保护,但库存下界、状态流转和多表提交还需要各自机制。画一个数据库方框并不会自动让所有操作拥有同一事务。
下面先用纯内存事实重建商户订单统计。程序验证用户、商户、券与订单关系,再聚合成交金额。成交价保存在订单中,不能回头用优惠券当前价格重新计算历史成交。这个练习把表之间的连线变成失败时可观察的检查:缺少关联对象、重复订单或违反一人一券规则都必须有明确结果,而不是静默跳过。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
U["用户事实"] --> N["探店笔记"]
S["商户事实"] --> N
S --> V["优惠券"]
U --> O["订单及成交金额"]
V --> O
O --> P["商户订单统计投影"]
U --> L["登录后生成有有效期的会话"]按顺序看,每一步做了什么
- 选择一个用户动作,写清输入、身份与返回字段。
- 列出事实实体及其主键、关联和业务唯一性。
- 将数据库事实、会话和查询投影标为不同生命周期。
- 给每个跨系统写入画出失败位置、补偿与重建入口。
03对照实现代码
Spring Framework v6.2.11 · BeanUtils#copyProperties(Object,Object,Class,String...);BeanUtils#isAssignable
以下是原创显式 DTO 映射,用于对照 BeanUtils 的通用属性复制边界;不是黑马点评实际源码,也不是 BeanUtils 源码节选。
import java.util.Objects;
class SourceModel {
record User(long id, String nickName, String icon, String phone, String passwordHash) {}
record UserDTO(long id, String nickName, String icon) {}
static UserDTO toDTO(User entity) {
Objects.requireNonNull(entity); // 输入实体存在并不代表请求已获得授权
if (entity.id() <= 0) throw new IllegalArgumentException("id");
return new UserDTO(entity.id(), entity.nickName(), entity.icon());
// 显式白名单只输出所需字段,不把手机号与密码摘要传给页面
}
static UserDTO ownProfile(long authenticatedUserId, User entity) {
if (authenticatedUserId != entity.id()) throw new SecurityException("forbidden");
return toDTO(entity); // 对象级授权在映射前完成,映射工具不能替代它
}
}真实 copyProperties 遍历目标属性描述符,寻找同名可读源属性与可写目标属性,并检查类型可赋值性后调用方法。
复制属性工具不会自动检查订单属于谁、当前用户是谁,或某字段是否敏感。这些允许复制与返回的规则,要由业务接口明确规定。
源对象多出的属性可能被忽略,类型不兼容也不会自动变成所需业务转换。String 编号到 Long 应显式解析并验证。
教学使用不可变 record 和构造器表达白名单,不依赖反射 setter;这减少字段边界的隐式行为,但仍要维护映射与测试。
代码先调用谁,再调用谁
- 服务端确认当前身份
- 查询业务事实
- 校验对象归属与状态
- 显式映射最小 DTO
- 序列化响应
04动手写一个例子
验证事实关系并重建商户订单投影
给定用户、商户、券和已成交订单,验证主键、关联与一人一券规则,再返回每家商户的订单数和成交金额。金额为分,历史成交额取订单字段;本题不覆盖退款、取消和库存预占。
- 输入 1
商户10的券100;两位用户分别成交90分、80分- 输出 1
商户10:订单2,金额170分
- 输入 2
订单用户不存在- 输出 2
拒绝关系缺失,不生成统计
为各实体构建按编号查找的映射,重复编号或非法编号立即拒绝,使后续关系查询拥有唯一目标。
订单逐条检查用户、券、商户一致性以及用户券组合唯一性,用结构化 Pair 防止字符串拼接碰撞。
通过校验的订单按商户累加,金额取成交快照并使用精确加法检测溢出;无订单商户仍输出零统计,避免报表漏项。
为什么这样写是对的每个被统计订单都有唯一合法身份与完整关联,恰好归入一家商户;每个用户券组合最多出现一次。
各输入集合扫描一次,关联和去重平均哈希查询为常数;索引、组合去重与结果空间随数据量增长。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
import java.util.function.ToLongFunction;
public class Main {
record User(long id) {}
record Shop(long id) {}
record Voucher(long id, long shopId, long currentPrice) {}
record Order(long id, long userId, long voucherId, long shopId, long paidCents) {}
record Pair(long userId, long voucherId) {}
record Stats(long count, long paidCents) {}
static <T> Map<Long,T> index(List<T> rows, ToLongFunction<T> id) {
Map<Long,T> map = new HashMap<>();
for (T row : rows) {
long key = id.applyAsLong(row);
if (key <= 0 || map.putIfAbsent(key,row) != null) throw new IllegalArgumentException("id");
}
return map;
}
static Map<Long,Stats> rebuild(List<User> users, List<Shop> shops,
List<Voucher> vouchers, List<Order> orders) {
Map<Long,User> us = index(users,User::id);
Map<Long,Shop> ss = index(shops,Shop::id);
Map<Long,Voucher> vs = index(vouchers,Voucher::id);
index(orders,Order::id); // 先校验订单主键,不能由聚合掩盖重复事实
for (Voucher v : vouchers)
if (!ss.containsKey(v.shopId()) || v.currentPrice()<0) throw new IllegalArgumentException("voucher");
Set<Pair> unique = new HashSet<>();
Map<Long,Stats> result = new HashMap<>();
ss.keySet().forEach(id -> result.put(id,new Stats(0,0))); // 零订单商户也有查询投影
for (Order o : orders) {
Voucher v = vs.get(o.voucherId());
if (!us.containsKey(o.userId()) || v==null || v.shopId()!=o.shopId() || o.paidCents()<0)
throw new IllegalArgumentException("relation");
if (!unique.add(new Pair(o.userId(),o.voucherId())))
throw new IllegalArgumentException("one order per user and voucher");
Stats old = result.get(o.shopId());
result.put(o.shopId(),new Stats(Math.addExact(old.count(),1),
Math.addExact(old.paidCents(),o.paidCents()))); // 成交快照不随当前券价变化
}
return Map.copyOf(result); // 只有全部输入通过后才发布结果
}
static void check(boolean ok) { if(!ok) throw new AssertionError(); }
static void bad(Runnable action) {
try { action.run(); } catch (IllegalArgumentException expected) { return; }
throw new AssertionError("expected rejection");
}
public static void main(String[] args) {
List<User> u=List.of(new User(1),new User(2));
List<Shop> s=List.of(new Shop(10),new Shop(20));
List<Voucher> v=List.of(new Voucher(100,10,999));
List<Order> o=List.of(new Order(1,1,100,10,90),new Order(2,2,100,10,80));
Map<Long,Stats> r=rebuild(u,s,v,o);
check(r.get(10L).equals(new Stats(2,170)));
check(r.get(20L).equals(new Stats(0,0)));
bad(() -> rebuild(u,s,v,List.of(new Order(1,3,100,10,90))));
bad(() -> rebuild(u,s,v,List.of(new Order(1,1,100,20,90))));
bad(() -> rebuild(u,s,v,List.of(o.get(0),new Order(2,1,100,10,80))));
bad(() -> rebuild(u,s,v,List.of(o.get(0),o.get(0))));
bad(() -> rebuild(u,s,List.of(new Voucher(100,30,10)),List.of()));
check(rebuild(u,s,v,List.of()).get(10L).count()==0);
System.out.println("8 business projection checks passed");
}
}这些特殊情况也要试一试
- 一人一券是本题业务规则,不是所有优惠券产品的普遍限制。
- 商户和券归属假定稳定;真实迁移和历史归属需要保存快照或版本规则。
- 退款与取消改变统计口径,不能直接把全部订单都当成已成交订单。
进阶选读 · 生产拓展:可重放的增量投影
场景订单量增长后不能每次从全部事实重建商户统计。
做法用带稳定事件编号的成交与退款事件增量更新投影,保存已处理位置并支持幂等;周期性按一致事实快照全量对账,发现差异可重建替换。
复杂度单事件平均 O(1) 更新;完整对账 O(o),存储成本取决于事件去重与保留窗口。
好处与代价读取更快,但事件顺序、重复、漏投与版本升级都需要恢复协议,不能只维护一个自增计数。
05项目里怎么用
业务导图要能回答失败之后怎么办。数据库提交成功而缓存删除失败,需要失效补偿;笔记已经发布而粉丝投影失败,需要重建或重投;验证码已消费但用户创建失败,需要重新验证或设计受控的后续步骤。这些流程的事实写入点不同,不能把任何异常都归为一次简单回滚。
先把数据模型说清,再决定是否拆成多个系统。同一个本地数据库事务能保护相关修改,但 Redis、消息和数据库不会自动共用它。能重建的查询副本,可以允许短暂延迟并安排恢复;不可丢的业务事实,则要先可靠保存,再异步生成查询数据,并让调用方能查到结果。
验收要覆盖无数据、关联缺失、重复请求、敏感字段和用户权限。字段名正确,不表示查的就是当前用户的数据;DTO 精简,也不表示订单存在或金额正确。把业务关系写成可运行检查,以后改缓存或并发实现时,才能确认原本规则仍成立。
容易踩的坑
- 把登录态、缓存、待处理事件都称为可随意删除的缓存。
- 用券的当前价格重算历史成交金额。
- 将数据库实体整对象直接作为对外接口或会话内容。
- 只画实体连线,不写唯一性、写入者和失败恢复责任。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 直接查询事实表 | 链路简单且便于核对 | 复杂聚合可能较慢 | 规模较小或一致性要求明确的入口 |
| 维护查询投影 | 可适配排序、计数和检索 | 需要同步、对账与重建 | 高频读取和可接受延迟的视图 |
| 显式 DTO 映射 | 字段与类型边界清晰 | 需要维护映射代码 | 会话和对外接口 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01为什么订单计数可以重建而登录令牌不能从用户表直接恢复?
展开参考答案与推导
计数有订单事实与聚合规则;有效令牌还包含独立的发行与有效期状态,不能从用户存在推出凭证有效。
02认证与授权在订单链路中分别做什么?
展开参考答案与推导
认证确认当前用户,授权限制其能读取或修改哪些订单。
03BeanUtils 能自动处理敏感字段吗?
展开参考答案与推导
不能。它按属性与类型复制,敏感性和业务权限必须由 DTO 契约及授权逻辑确定。
换个情况,还会处理吗
01优惠券改价后历史报表总金额变化,哪里可能错了?
展开参考答案与推导
报表可能使用当前券价而非订单成交快照,应按历史金额和状态口径统计。
02Redis 清空后要恢复什么?
展开参考答案与推导
先区分可由事实重建的投影、需要重新登录的会话,以及是否另有可靠记录的未处理事件。
03数据库已提交而投影失败,能直接宣称订单失败吗?
展开参考答案与推导
不能。事实可能已经成功,需要查询结果并补偿投影,避免用户重试造成重复业务。
写代码试一试
01两个用户成交90分与80分,当前券价999分,汇总多少?
- 输入 / 条件
90、80、当前999- 输出 / 预期
170分
提示 取成交快照。
展开参考答案与推导
当前价格不参与历史合计,计数为2。
02同一用户同一券两条不同订单编号是否通过?
- 输入 / 条件
一人一券规则- 输出 / 预期
拒绝组合重复
提示 主键唯一与业务唯一分开。
展开参考答案与推导
订单编号不同只满足主键约束,仍违反用户券组合唯一性。
03无订单商户应从统计中消失吗?
- 输入 / 条件
商户存在,订单为空- 输出 / 预期
订单0,金额0
提示 先初始化投影。
展开参考答案与推导
本题保留所有商户,以零值区别不存在商户与没有成交。
读完之后,独立完成
课后作业:自己做一遍
本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能画出一个请求经过接口、数据库和 Redis 的数据流,并标出事实来源。
本篇重点加练
主外键只能表达部分关系,可靠业务事实还需明确发生时间、修改责任和追溯口径。
- 主外键关系
MySQL业务事实
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
点评站允许商户停业、用户注销以及撤回评价。分别定义这三种动作对历史评价和关注关系的影响,指出尚需产品确认的规则。
- 提交内容
- 实体关系图与规则清单
- 怎样算完成
- 区分实体身份和展示字段
- 覆盖不存在及已注销主体
练到的知识点用户与商户 · 评价与关注
- 作业 02设计评审
同一商户可发行多种券,一张券允许分批发售。评审“订单只存商户名称和购买金额”的表设计,并提出能追溯购买对象的字段约定。
- 提交内容
- 字段字典与关联约束表
- 怎样算完成
- 说明各字段事实含义
- 包含改名和停售场景
练到的知识点券与订单 · 主外键关系
- 作业 03代码推演
画出匿名查看商户、登录发布评价、登录查看订单三条请求链。请求体都允许携带
userId时,逐一标明该字段在哪一步被解释。- 提交内容
- 三张数据流图
- 怎样算完成
- 标出身份来源和资源归属检查
- 区分缓存读取与持久写入
练到的知识点登录态 · 接口数据流
- 作业 04原理问答
同一个用户可以先发布评价、后补录订单。分别定义评价关联订单是强制关系还是可选证据,说明数据库约束能检验和不能检验的业务真实性。
- 提交内容
- 关系约束与证据表
- 怎样算完成
- 区分关联存在和事件确实发生
- 标明规则来自技术还是产品要求
练到的知识点主外键关系 · MySQL 业务事实
综合应用
- 作业 05面试场景
数据库有退款记录,Redis 商户成交榜只按下单次数累加。运营要求昨日净成交额,给出核对口径与数据来源判定。
- 提交内容
- 口径说明与对账样本
- 怎样算完成
- 区分事实与派生结果
- 包含退款跨日和重复事件
练到的知识点MySQL 业务事实 · Redis 派生数据
- 作业 06编程实践
编写本地 Java 校验器读取小型商户、券、订单、退款数据,生成异常清单与商户日统计;统计口径自行声明,禁止静默吞掉异常记录。
- 提交内容
- 源码、输入数据和运行记录
- 怎样算完成
- 覆盖孤立关联、重复编号和金额边界
- 说明异常记录是否影响整个批次
练到的知识点用户与商户 · 券与订单 · 主外键关系 · MySQL 业务事实 · Redis 派生数据
- 作业 07代码推演
两次导入都使用从 1 开始的商户局部编号,随后订单文件混合到一起。构造最小样本,判断仅验证编号存在会遗漏什么信息。
- 提交内容
- 数据样本与关联判定记录
- 怎样算完成
- 区分编号域和全局身份
- 记录每条关联的来源
练到的知识点主外键关系
- 作业 08面试场景
客服误改已成交订单金额,现有日志只保存最终行内容。制定事实追溯需求,并评审能否从当前表恢复修改前状态。
- 提交内容
- 信息缺口及审计需求清单
- 怎样算完成
- 已知与无法确定分开
- 明确谁能修改何种事实
练到的知识点MySQL 业务事实
- 作业 09编程实践
写本地 Java 数据血缘检查器:读取接口字段到表字段的声明,列出无来源字段、环形依赖和引用未知事实的字段;输入只用少量虚构数据。
- 提交内容
- 程序、依赖文件与检查记录
- 怎样算完成
- 检查结果能定位字段路径
- 声明静态声明无法证明真实写入行为
练到的知识点主外键关系 · MySQL 业务事实
进阶挑战 · 掌握前置后选做
- 作业 10设计评审
新增“商户合并”功能:两家商户合并展示,但历史评价与订单需要可追溯。给出迁移前后读写规则以及中断后的核验方案。
- 提交内容
- 变更方案与验收矩阵
- 怎样算完成
- 覆盖迁移期间请求
- 区分历史事实和新展示投影
练到的知识点评价与关注 · MySQL 业务事实 · Redis 派生数据 · 接口数据流
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 11思维拓展 · 反例构造
把“数据库中有订单就证明交易发生”的说法作为待审查命题,构造至少两种来源不同的反例,并说明还需要何种外部事实才能排除它们。
- 提交内容
- 反例与证据需求
- 怎样算完成
- 反例改变证据条件而非仅改数值
- 不预设外部证据永远可信
练到的知识点主外键关系 · MySQL 业务事实
- 作业 12思维拓展 · 跨域迁移
将点评系统的事实与投影划分迁移到实验室样本管理:样本会拆分、合并并撤回检测结果。哪些关系和追溯规则需要重新定义?
- 提交内容
- 跨域模型与迁移说明
- 怎样算完成
- 包含不可逆实验动作
- 逐项说明沿用和新增的约束
练到的知识点主外键关系 · MySQL 业务事实
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
MySQL 8.4:CREATE TABLE (新标签页)↗
阅读列类型、空值、主键与唯一约束,把业务不变量落实到表结构。
- 实践教程英文
Redis:数据类型 (新标签页)↗
按业务操作比较
String、Hash、Set、SortedSet与 Stream,避免只按名称选型。 - 深入实践指南英文
Microsoft Azure:Cache-Aside 模式 (新标签页)↗
阅读读写步骤、过期策略与一致性限制,设计缓存失效和回源实验。
- 深入参考文档英文
MySQL 8.4 外键约束 (新标签页)↗
把实体关系转为可检查的关联约束。
- 深入参考文档英文
MySQL 8.4 InnoDB 事务模型 (新标签页)↗
理解多个事实修改的本地事务边界。
- 深入参考文档英文
Spring 6.2.11 BeanUtils 源码 (新标签页)↗
区分属性复制、类型转换与业务映射。
- 深入参考文档英文
OWASP 授权指南 (新标签页)↗
为每个请求和业务对象检查访问权限。