支付系统设计评审:一次增量讨论补足的五个缺陷

一份支付设计方案,是怎么被审出五个洞的 某开发者在设计一套统一支付服务。初版方案自认为考虑周全:支付订单表、退款表、渠道配置、对账批次,表格一张比一张漂亮。然后跟组里人过了一遍设计,被一个问题接一个问题地问到改稿——每一问都补出一个之前没想透的缺陷。 这篇就把这次增量讨论里补上的五个点记下来。它们不是"支付系统特有的冷知识",而是任何一个会分库分表、会对账、要复用的系统都可能踩的坑。 场景:一个要复用的统一支付服务 先交代背景。这套支付服务的目标是: 从"模拟支付"改造成真实支付——聚合支付宝、微信支付、微信小程序支付,未来还可能接更多国内渠道 拥有自己的数据库(此前完全没有,支付数据存在别处) 带一套每日对账系统——拉取渠道对账单、解析、批量入库、逐笔对比、算差异 日后作为独立支付服务接入其他项目(商城只是第一个业务方) 正是"要复用"和"要对账"这两点,引出了下面五个洞。 洞一:对账的逐笔对比写了 JOIN 初版对账设计里,逐笔对比是这么写的: -- 渠道账单临时表 LEFT JOIN 本地支付订单表 SELECT t.trade_no, t.amount, o.pay_amount, ... FROM recon_temp t LEFT JOIN pay_order o ON t.trade_no = o.merchant_order_no; 乍看没毛病——两张表同库、有公共键。但评审的人问了一句:"pay_order 分库分表之后,这个 JOIN 还能跑吗?" 不能。跨表 JOIN 需要分片键能路由到同一分片,而 recon_temp 和 pay_order 的分片键不同(一个按批次、一个按用户),JOIN 会退化成全分片广播扫描——每一个分片都扫一遍再合并,数据量一大就是灾难,更别说分片规则一变直接报错。 📌 前置知识:分库分表后,跨表 JOIN 要保证两张表的行落在同一个分片(同分片键)才能路由;分片键不一致时只能广播到所有分片再内存合并。 改法:双批查询 + 内存撮合。 flowchart TD A["渠道对账单文件"] -->|"解析"| B["recon_temp 当日明细"] C["pay_order 支付订单表"] -->|"按渠道×时间窗查询"| D["当日支付单子集"] B -->|"批查加载"| E["渠道侧内存 Map"] D -->|"批查加载"| F["平台侧内存 Map"] E -->|"按 merchant_order_no 匹配"| G["逐笔撮合"] F -->|"按 merchant_order_no 匹配"| G G --> H["命中 / 仅渠道 / 仅平台"] H --> I["差异写 recon_result"] classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:2px,color:#e5e7eb; classDef data fill:#052e16,stroke:#16a34a,stroke-width:2px,color:#bbf7d0,font-weight:bold; classDef condition fill:#2a1147,stroke:#a855f7,stroke-width:2px,color:#ede9fe,font-weight:bold; class A,D process class B,C,F data class G,E condition class H,I process 两个单表查询各自按自己的分片键路由( recon_temp 按 batch_no 、 pay_order 按 user_id ),各自只取撮合需要的列,在内存里建 HashMap 按 merchant_order_no 精确匹配。全程无跨表 JOIN——这就是"无联表"原则,也是后续分库分表的前提条件。 ...

十月 27, 2023 · 3 分钟 · 621 字 · yaomingye

数据库金额字段该用 decimal 还是 bigint:从历史惯例到现代支付栈的选型之路

钱的字段到底该用 decimal 还是 bigint 某开发者最近在设计一套统一支付服务,走到金额字段这一步,跟数据库里的老订单表吵了一架:新表想用 bigint 存"分",老表是 decimal(10,2)。写代码前先把这个历史遗留问题捋清楚,发现这背后是一整段软件史。 数据库里的金额字段,可能是除了主键之外被争论最多的一种类型。打开任何一本数据库教材,都会看到一句名言——“钱的字段千万别用 float”。但这句话的下半句往往没人讲:不用 float,那到底用 decimal 还是 bigint? 教科书里写的是 decimal。现代支付 API 的契约里写的是"整数最小单位"——也就是 bigint 存分。两边都合理,为什么结论会分叉? 从一次选型冲突说起 设计支付服务时,金额字段出现了两个候选人: ** decimal(10,2) ** —— 存的就是 100.00 ,肉眼可读 ** bigint ** —— 存 10000 ,单位是分,代码里到处都是 ÷100 老 ERP 系统的订单表选了前者,支付服务想选后者。这不是口味问题,是两个时代的设计碰撞。要理解它,得先从 float 为什么被禁说起——因为 float 才是那个真正不配碰钱的类型。 📌 前置知识:浮点数、定点数、IEEE 754 这三个概念是本文的地基,建议先有个印象再往下看。 float 的罪与罚:二进制算不清十进制 先复现那个经典翻车现场: SELECT 0.1 + 0.2; 结果是 0.30000000000000004 。 float / double 用二进制科学计数法存储: M × 2^E ,M 是尾数,E 是指数。但十进制小数 0.1 转成二进制是无限循环小数: ...

十月 25, 2023 · 3 分钟 · 631 字 · yaomingye

AQS 源码重走:从 Doug Lea 的 CLH 变体到 JDK 21 的完整路径

AQS 源码重走:从「如果让我写」到「原来还可以这样」 某开发者背了一周 AQS 八股:CLH 队列、acquireQueued、shouldParkAfterFailedAcquire……面试官问「AQS 的 prev 和 next 为什么一个可靠一个不可靠?」——答了,但说不出这么设计是为了解决什么问题。面试官一句话戳穿:「你读过源码,但你有没有想过在并发场景下删队列中的一个节点,不这么写会发生什么?」 背源码最大的问题是:你不知道那些代码是在解决什么问题。 所以这篇文章不走寻常路——先问「如果不用 AQS,让我自己写一个锁排队框架,该怎么下手?」然后一步步推演,当你发现「哎这里不安全怎么办」的时候,再引出 Doug Lea 的解法。这个过程会让人惊叹:原来并发代码还可以这样写。 最后,会专门对比 JDK 8 和 JDK 21 两个版本的 AQS,讲清楚 Doug Lea 为什么在 JDK 21 中对核心逻辑做了一次大重构。 🏗️ 从零开始:如果让我实现一个可排队的锁 假设只有 synchronized 和 LockSupport.park/unpark ,让你实现一个「锁没抢到就排队等」的框架,你打算怎么写? 第一次尝试:一个粗暴的实现 // 初版思路 class NaiveLock { volatile int state = 0; // 0=未锁, 1=已锁 Queue<Thread> queue = new ... // 等待队列 void lock() { while (!CAS(&state, 0, 1)) { // 没抢到锁 queue.enqueue(currentThread()); // 入队 park(); // 阻塞 } } void unlock() { state = 0; // 释放锁 Thread t = queue.dequeue(); // 从队列取一个 unpark(t); // 唤醒 } } 看上去好像没问题。但仔细想想,全是坑: ...

十月 21, 2023 · 20 分钟 · 4200 字 · yaomingye

HashMap 源码十八拷:从 put/get 到红黑树,八股文背后的 JDK 设计逻辑

打开 HashMap.java,从第 1 行读到第 2587 行 某开发者背了三天八股,面试官问「HashMap 怎么定位桶的」——「计算 hashCode,扰动,然后 (n-1) & hash 」。面试官点头又问:「那为什么要扰动,直接 (n-1) & hashCode 不行吗?」——卡住了。 其实 HashMap.java 的开头注释里写得很明白:Because the table uses power-of-two masking, sets of hashes that vary only in bits above the current mask will always collide. 因为用的是 2 的幂掩码,高位不同的 key 会撞。这才是设计动机,不是「某大牛说 XOR 一下好」。 本文换个路子,打开 JDK 21 的 java.util.HashMap (2587 行),从上到下、按源码书写顺序走一遍。每遇到一个常量、一个方法、一个分支,不只说它是什么,说它为什么是它。 第一站:类声明和那篇著名的注释 // HashMap.java:139 public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable { 类签名平平无奇,真正的宝藏从第 145 行开始——一段大几百字的 Implementation notes。这大概是 Java 标准库里含金量最高的注释之一,建议每个读源码的人在这停个十分钟。 ...

十月 19, 2023 · 16 分钟 · 3203 字 · yaomingye

从语法迷雾到内存物理本质:C 指针 / Java 引用 / Go 接口 / C++ 类的底层统一模型

语法迷雾之下,物理内存里只有「数值」与「地址」 一、导言:被抽象概念包围的困惑 某开发者学了三年 Java,突然被扔去写 C——struct 是什么? *p 又是什么? void * 是什么东西?回头再看 Go,interface 怎么不用 implements ?再翻翻 C++ 源码,一个 class 里又是 virtual 又是 this——每个语言都有一套自己的"数据创造论",把内存的本质层层包裹起来。 Java 说「一切皆对象」,C 说「一切皆指针」,Go 说「用组合不要继承」,C++ 说「我全都要」。刚入门的读者站在这些口号中间,CPU 和 RAM 到底是怎么看待这些概念的? 破局点只有一句话:CPU 和 RAM 根本不懂面向对象。物理内存里永远只有两样东西——「数值」与「地址」。 int 是数值,指针是地址,对象的字段是数值和地址的排列组合,接口变量是两个地址凑一对。所有语言的语法特性,最终在内存里都还原为这个二元模型。 flowchart LR subgraph APP["应用层"] JAVA["Java: 对象/引用"] CPP["C++: class/虚表"] GO["Go: struct/interface"] C["C: struct/指针"] end subgraph COMPILER["编译器/Runtime"] LANG["语法糖脱糖"] LAYOUT["内存布局计算"] VTABLE["虚函数表生成"] end subgraph HARDWARE["物理层"] VAL["数值"] ADDR["地址"] end JAVA & CPP & GO & C -->|"编译/解释"| COMPILER COMPILER -->|"最终形态"| HARDWARE classDef appStyle fill:#2d2522,stroke:#ea580c,stroke-width:2px,color:#f8fafc; classDef compStyle fill:#1e293b,stroke:#0284c7,stroke-width:2px,color:#f8fafc; classDef hwStyle fill:#052e16,stroke:#16a34a,stroke-width:2px,color:#bbf7d0; class JAVA,CPP,GO,C appStyle; class COMPILER compStyle; class VAL,ADDR hwStyle; 这篇文章的任务:把 C、C++、Java、Go 的语法糖一颗颗剥开,露出底下那个统一的物理内存画卷。 ...

十月 17, 2023 · 6 分钟 · 1191 字 · yaomingye

内存管理的破局者:虚拟地址、物理地址与页表——从哲学追问到公式推导

虚拟地址、物理地址与页表:从懵圈到通透只差这篇文章 一、起源:一个看似简单的哲学追问 1.1 初始疑惑:虚拟地址和物理地址一样,也是面向 CPU 的编号吗? 一个常见的困惑:虚拟地址(Virtual Address,VA,程序看到的地址)和物理地址(Physical Address,PA,硬件真正使用的地址)都是数字,那它们到底有什么区别?某位开发者曾盯着 printf("%p", ptr) 打印出的十六进制数,天真地以为这个数字就是内存条上的物理位置——直到他发现进程的虚拟内存空间比物理 RAM 还大,才意识到事情没那么简单。 核心区别在于它们服务的 CPU 阶段不同: VA(虚拟地址):面向程序与 MMU(Memory Management Unit,内存管理单元)。CPU 在执行 LOAD 指令时,发出的第一个请求就是 VA。程序中的每一个指针、每一个变量地址,都是在 VA 空间中定义的。 PA(物理地址):面向 RAM 与内存控制器。经过 MMU 翻译之后,VA 变成 PA,才是内存条上真正的硬件编号。内存控制器只认 PA,它不关心也不理解虚拟化这件事。 来看一段最直观的 C 代码: #include <stdio.h> #include <stdlib.h> int main() { int x = 42; int *ptr = &x; // ptr 的值是一个虚拟地址,不是物理地址 printf("变量 x 的虚拟地址: %p\n", (void *)ptr); printf("变量 x 的值: %d\n", *ptr); return 0; } ptr 里存储的地址是 VA。当 printf 读取 *ptr 时,CPU 发出 LOAD [ptr] 指令,硬件自动经过 MMU 翻译为 PA 之后才去 RAM 中取值。整个过程对程序员透明——这也是为什么很多人长期把 VA 误当作「内存的真实坐标」。 ...

十月 15, 2023 · 7 分钟 · 1369 字 · yaomingye

RocketMQ 存储模型硬核拆解:NameServer → CommitLog → ConsumeQueue 三层架构精析

RocketMQ 存储模型拆解:别再拿它当 BlockingQueue 用了 0. 引言:一个 Javaer 的认知崩塌 每一个刚接触 RocketMQ 的 Java 开发者,大概都会经历一次认知崩塌: 翻开 RocketMQ 源码,找遍所有 package,也找不到一个像 BlockingQueue 那样的 Queue 实现。作为一个"消息队列"(Message Queue),它的 Queue 到底在哪里?如果找不到 Queue 对象,消息是怎么"入队"和"出队"的? 答案很残酷,但也很优雅:RocketMQ 根本没有什么 Queue 数据结构。 RocketMQ 的 Queue 是一个文件夹——硬盘上的文件夹。消息不是"入队",而是 append 到磁盘文件末尾。消费者不是"出队",而是从磁盘文件读取一段定长的字节数组。 整个 RocketMQ,本质上就是一套精心设计的磁盘文件操作方案。 所有的分布式消息特性——消息重试、死信队列、消息积压、限流熔断——都是在这套磁盘文件模型上玩出的花样。理解了 RocketMQ 的文件长什么样、文件之间怎么关联,你就理解了 RocketMQ 的一切。 这篇从最底层开始,一层层往上拆,共七层: NameServer(路由层)——只管路标,不存消息 CommitLog(存储层)——所有消息顺序写入的大文件 ConsumeQueue(索引层)——被误称为队列的定长指针数组 Topic 参数(运维配置)——目录数量和权限的配置映射 高级特性——基于文件模型的策略实现 开发视角——你的代码在操作什么 总结——一张物理模型图覆盖所有概念 1. 第一层:NameServer(路由层) NameServer 是 RocketMQ 的路由层。它是一个轻量级的注册中心——只维护 Topic 到 Broker 地址的映射关系,不存储任何消息数据。 传统模式 在 RocketMQ 的传统模式下,NameServer 内存中维护着两张核心映射表: BrokerData:Topic 名称 → Broker IP 列表。消费者拿到这个列表,就知道该从哪台机器拉数据。 QueueData:Queue 数量 + 权限设置。Broker 上报时携带每个 Topic 配置了多少 Queue。 生产者发消息时先问 NameServer:“TopicA 在哪台机器上?有几个 Queue?” NameServer 返回 Broker 地址列表,生产者选择一个 Broker 发过去。消费者同理。 ...

十月 13, 2023 · 7 分钟 · 1408 字 · yaomingye

从 Java/Go 到 React:一个后端程序员的 TypeScript 受难与破壁实录(附 Flutter 做中间翻译器)

从 Java/Go 到 React,顺便拉 Flutter 垫背 一、前言:为什么后端程序员学 React 想骂人? 某后端组有天接了个需求:用 React + TypeScript 写个管理后台。组里人均三年 Spring Boot 或 Go Gin 经验,前端认知停留在 jQuery 版本。一开始想的是"TS 不就是带类型的 JS 嘛,有类型就不慌"——结果打开第一个 React 教程就傻了:函数组件、Hooks、闭包陷阱、依赖数组、JSX 里嵌逻辑……这哪是前端,这分明是另一个世界。 后端思维高度固化:类继承、接口实现、线程阻塞、强类型、反射——这些概念在 Spring 和 Go 里是护城河,在 React 里是全都没用的东西。类?函数组件不需要。接口?TS 的结构化类型不需要显式 implements。线程?JS 单线程事件循环,根本没有多线程。 Flutter 被拉进来做"中间翻译器":Dart 语法像 Java,但框架思维像 React。先写 Flutter 再写 React,会发现很多映射:Widget 树 >= 虚拟 DOM,setState >= useState,initState/dispose >= useEffect。把 Flutter 当"桥梁",Java/Go 当"起点",React 就没那么陌生。 为什么不直接对比?因为 Java 和 TS 差距太大——标称类型 vs 结构类型,多线程阻塞 vs 单线程事件循环。中间垫一个 Flutter(标称类型 + 单线程异步 + 声明式 UI),过渡就平滑了。 ...

十月 11, 2023 · 16 分钟 · 3256 字 · yaomingye

今日日报:Redis Lua 原子脚本与三段式库存模型——从商品表拆出独立库存微服务

拆库存服务:库存不是商品的附属品 某天盯着商品表的字段列表,发现 quantity、remain_quantity、sale_count 这三个东西怎么看怎么和 name、price、cover_url 不是一家人。name 改了不频繁,库存每秒都在扣——高频写和低频读挤在同一行,互相锁着玩。 决定拆。新建了一个 mall-inventory 微服务,独立数据库 cloud_mall_inventory,三张表:inventory(主库存)、inventory_batch(批次追踪)、inventory_log(变动流水)。 最头疼的问题:扣库存的一致性 库存扣减最怕两个事:超卖和半截崩溃。 第一个做法是两条 Redis 命令: redisUtil.increment(key, -quantity); // 扣 available redisUtil.increment(frozenKey, quantity); // 加 frozen 问题很明显——第一条执行完、第二条还没跑的时候,机器崩了怎么办?available 扣了但 frozen 没加,库存"凭空消失"了。 解法是 Lua 脚本,把两条操作打包发给 Redis: local qty = -tonumber(ARGV[1]) local avail = redis.call('INCRBY', KEYS[1], qty) if avail < 0 then redis.call('INCRBY', KEYS[1], -qty) return -1 end redis.call('INCRBY', KEYS[2], -qty) return avail - qty Redis 内部保证整个脚本一次性原子执行,不存在中间状态。Spring Data Redis 的 StringRedisTemplate.execute(script, keys, args) 直接调用就行。 三段式库存模型 完整链路改成了"冻结 → 确定 → 释放": 下单 → frozen +1, available -1(冻结) ├─ 支付成功 → frozen -1, sale_count +1(确认扣减) └─ 超时/取消 → frozen -1, available +1(释放) 之前是下单直接扣 remain_quantity,30 分钟后超时取消还要回滚。但回滚依赖 MQ 消息,MQ 挂了库存就永远不恢复了。新模型不存在这个问题——「可用库存」只负责「卖」,frozen 只负责「锁」,职责拆开,逻辑自洽。 ...

七月 9, 2023 · 2 分钟 · 237 字 · yaomingye

BFF 层接口规范化踩坑记:从 Object 到 DTO 的全面改造

BFF 重构:接口规范化流水账 从 “Object e” 和 “Map c” 说起 项目里有一个 BFF 层,最初的写法长这样: @PostMapping("/xxx/insert") public ApiResult<Integer> insert(@RequestBody Object e) { ... } @PostMapping("/xxx/page") public ApiResult<ResponsePageEntity<?>> page(@RequestBody Map c) { ... } 看起来很省事对吧?一个 Object 通吃所有入参,一个 Map 搞定所有查询条件。但问题在于——Swagger 上完全看不到请求体结构,前端看着文档只能看到 {},根本不知道要传什么字段。 于是这轮干了一件脏活累活:把 BFF 层所有接口的 @RequestBody Object 和 @RequestBody Map 全换成了具体的 DTO 类。总共涉及约 50 处改动,覆盖了认证、用户、商品、订单、营销等全部模块。 改完之后的效果: @PostMapping("/xxx/insert") public ApiResult<Integer> insert(@RequestBody XxxDTO entity) { ... } @PostMapping("/xxx/page") public ApiResult<ResponsePageEntity<?>> page(@RequestBody XxxConditionDTO c) { ... } 前端看 Swagger 终于能看到每个字段的名、类型、枚举值、示例——不需要反复在群里问"这个接口传什么"了。 ...

七月 5, 2023 · 2 分钟 · 334 字 · yaomingye
Cat Radio