# 小钱袋 Node 应用开发经验总结

小钱袋是蚂蚁金服体验技术部基于余额宝子卡开发的一个产品，家长通过在开通一个余额宝子卡，可以给孩子创建一个单独的存钱账户，可以支付宝中搜索小钱袋找到应用。

## 特色

小钱袋就是支付宝上一个非常简单小应用，但是因为来自于体验技术部，所以小钱袋应用整体技术栈都是 js 的，和大部分面向用户端的 node 应用不一样的点在于，**小钱袋不仅仅是 BFF 应用，我们有自己完整的数据层**。

很多来自前端团队的内部产品也都会有自己的数据层，比如 凤蝶、basement 应用，和这些应用相比小钱袋最大的特点是，小钱袋用户是支付宝 C 端用户。**依托于支付宝 App ，我们可以运营的用户是亿级别的，产品要做到千万级用户规模，并不算很难**。

总结来说，技术上有两个特点

* 小钱袋是有数据层
* 面向 C 端用户，用户规模 1000w+

下面我们主要来讲一下，这样一个应用如何基于 NodeJS 开发。

## TypeScript

### 必要性

2016 年开始做第一个产品时，当时我做的第一个决定就是后端使用 TypeScript 。在这之前，我在做凤蝶，凤蝶应用发展到第三年，代码越来越难以维护，中间经过两次大的架构升级，但历史的包袱太重了，后来云凤蝶彻底重写了。

凤蝶最初还是 chair + java 结构部署的，我们都有参与 java 应用的开发，相对说 java 应用要好维护得多。我觉得**强类型更适合这种多人维护的复杂应用**。

解决代码腐化的方案

* 单元测试，测试保证修改后代码还能正确运行
* 重构，再好的架构设计，也都需要不停进化的

凤蝶最初试遇到了很严重的稳定性问题，后面我们把测试用例补全，但是代码还是不可逆的变得难以维护。单元测试可以部分提高稳定性，但是对于可维护性还是不够。

TypeScript 的主要优势

* **增加代码和数据之间的强关联，并且这种关联是具有约束性的**
* **函数有准确的接口和入参**

#### 强大的类型体系

函数接口 js 中也可以通过繁琐的注释来声明，但是注释的表达性比起 TypeScript 语言级支持来说，只能算是一个小宝宝，并且 TypeScript 的类型体系还在不断进化。比如 ts 中我们可以声明一个这样描述函数：

```typescript
// example 1
function validate<K extends string>
  (payload: unknow, fields: K[]): { [key in K]: string; } {
  // function body
}
// const data: { a: string; }
const data = validate({ a: 'a', b: 'b' }, ['a']);

// example 2
type QueryFilter<T> = Partial<{ [P in keyof T]: T[P] | Array<T[P]>}>;
function query<T>(filter: QueryFilter<T>): Promise<T> {
  return this.mysql.get('table', filter);
}
```

第一个例子，data 可以动态得到类型 `{ a: string}`  。第二个例子，可能不大好看懂，实际上就是 egg-mysql 底层 ali-rds 的 get 方法。我们可以看到官方文档[接口描述](https://github.com/ali-sdk/ali-rds#get)是这样的，对应加上接口描述后。

 ![](https://iling.fun/api/files.get?sig=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1cGxvYWRzL2MyMzFmODM0LTY3ZGQtNGJjZi1iM2RmLWYxMTQ2NGUxM2Y0MS8xYWI0Mjc3Zi0zM2I5LTRkNzMtYjVlMS1kZTRhYmUyZjc4Y2UvaW1hZ2UiLCJ0eXBlIjoiYXR0YWNobWVudCIsImlhdCI6MTc5MDQzNzUwMCwiZXhwIjoxNzkwNTIzOTAwfQ.cwANvR-S8IvjXoHJfJs6Y8nNoVqUFI5QJuGA60EiSUw)   ![](https://iling.fun/api/files.get?sig=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1cGxvYWRzL2MyMzFmODM0LTY3ZGQtNGJjZi1iM2RmLWYxMTQ2NGUxM2Y0MS81ODQ0NzM3YS0wNTEyLTQyMTgtOTA3Zi1iOWE4ZTIxMzlhYWIvaW1hZ2UiLCJ0eXBlIjoiYXR0YWNobWVudCIsImlhdCI6MTc5MDQzNzUwMCwiZXhwIjoxNzkwNTIzOTAwfQ.A47As-4jKqoJSqXNaL6wZSUqYeX9pIlcsQyDN6xef78)

这里类型是支持运算的，注释无论如何也是做不到的，这种类型描述有很多，可以极大提高函数透明度。上面函数我们不需要写注释，函数本身接口可以说明函数如何运行。

#### 数据和代码之间关联性

此外，我们可以解决重构 js 代码最大难点，代码和数据之间是割裂的，我们有一个模型，这个模型在哪里使用，在哪里被修改是不可知的。

已有的数据模型中随意增加一个字段，或者给字段增加一种枚举类型，在 js 中是很艰难的，改动的时候缺少数据和代码之间关联性，风险不可知，这时候我们通常选择不断新增字段，老的字段继续保留，这样数据模型会越来越臃肿。

\*\*TypeScript 增加的类型，让代码关联性更加明确。\*\*比如我们的数据层基本结构如下

所有的方法都和泛型 T 自动关联，这样后面对数据层的所有操作都可以和 Data Object 接口关联上，数据和代码真正关联上，后面重构会非常方便。

**有类型，就这一个理由就非常值得我们尝试了**。虽然当时整体上基础设施还非常差，蚂蚁这边很少应用使用 ts 。而后面我们的应用经历过好几次大的技术升级改造，TypeScript 确实给予我们很多的帮助。

下面先说说我们使用过程中遇到的问题

### 发展过程

egg 使用 TypeScript 主要在于解决这几个问题。

* 异常定位
* 类型规范
* 最佳实践

#### 异常定位

最开始，最担心的是异常定位问题，ts 运行是编译后的 js ，正常情况错误拿到的应该是编译后的 js 堆栈。如果异常定位是错误的，对于后续线上问题排查是非常麻烦的，这是基础能力。

这个问题，社区很早之前就有解决方案。V8 提供了一个自定义错误堆栈的接口 [Stack Trace API](https://v8.dev/docs/stack-trace-api)，基于这个接口我们可以在异常抛出的时候错误定位到 ts 源码上，具体过程参考 [Node.js 中 sourcemap 使用问题总结](https://www.yuque.com/eward/bt8ny4/rdztpf) 。

#### 类型规范

使用 TypeScript 最大的一个问题就是外部依赖需要类型声明，chair 当时并没有类型声明，当时几个内部应用比如凤蝶、basement 各自写了自己的类型描述文件。chair 开源后，外部也很多人问题类型的问题，chair 也就有了类型的需求。这个问题好解决，纯粹体力活，后来我发了一个 [pr](https://github.com/eggjs/egg/pull/760) ，把第一版类型文件加上了。有了官方版本后，大家使用类型有了一个公共的约定。

#### 最佳实践

前面几个点主要是使用的基础设施问题，这些基础功能完成了。后面大家就想能不能把最佳实践整理下，让更多人方便使用。

当时遇到主要几个问题

* 开发环境什么时候编译 ts ，如何调试
* 类型声明怎么写

第一个问题，一开始都是开启 watch 实时编译 ts ，后来 egg 的 loader 支持 ts 加载，也可以在单元测试的时候直接依赖 ts-node 运行。这个问题天猪[改造](https://github.com/eggjs/egg/issues/2272)了 egg 底层，终于完善了。

第二个问题，因为 egg 底层是自动加载，service 是自动注入到请求上下文的，最初的方案，我们需要写了一个 service 文件后，需要把这个 service 的类型手动 merge 到请求上下文中。这个后来 egg 官方推荐的是 egg-ts-helper ，自动生成这个类型描述文件。

```typescript
import News from './News';

declare module 'egg' {
  interface IService {
    news: News;
  }
}
```

#### 自动注入 vs 依赖注入

egg-ts-helper 是一种解决方案，但这种方案我感觉并不理想。这个描述文件手动还是自动，我觉得并不核心问题。核心问题在于 egg 底层的自动加载机制。egg 应用通过目录约定，自动把所有的服务都挂载到请求上下文。这也就是 egg 奉行的 [**约定优于配置**](https://eggjs.org/zh-cn/intro/) 原则。

这种方式优点是使用简单，所有的服务可以随便用。但是，**对于复杂应用来说，service 分层是必要的**，**所有的服务都注入到上下文对象，这样不利于服务分层**。

当时我看到凤蝶重写的时候使用了依赖注入，从凤蝶最开始到后来的小钱袋，我们都慢慢遇到了需要对 service 进行分组、整理的需求，这时候自动注入反而不合适。我们可以通过目录来隔离，但那种还只是约定上的隔离。依赖注入的方式，让我们显式申明了依赖的服务，也可以解决类型需要额外手动 merge 的问题。

依赖注入的引入，实际上更多是一种心理上的负担更小。在写一个服务的时候，需要用到那个服务的时候，声明一下依赖，这样我们面对的就不是所有服务。这种方式在多人开发的情况，更不容易乱。

当一个维护者面对一个 service 文件时候，如果他可以一眼看到这个服务依赖，这样他只需要了解依赖那么几个服务。如果他需要面对整个应用所有的服务，这样可能更加容易无从下手。

所以，后来我们放弃了 egg 自动注入，采用了依赖注入的方式来处理依赖。并且，对 service 进行了分层。这里给 [egg-di](https://github.com/shepherdwind/egg-di) 打一个广告，使用 TypeScript 的开发者可以看看。

依赖注入使用的基本例子如下

```typescript
import UserService from 'service/user/user';
import { inject } from 'egg-di';

export default class UserController extends BaseController {
  @inject() readonly userService: UserService;
  async createUser(payload) {
    await this.userService.createUser(payload);
  }
}
```

## 分层架构设计

### 服务分层的基本原理

选择依赖注入，服务之间可见性是可控的，我们可以从思维上摆脱 chair 自动注入导致的服务不可隔离问题。同时可以突破 chair 应用目录约束，服务可以在 npm 包中，也可以在任意其他目录。

服务分层，需要做到分层之间满足以下规则

* 分层之间数据是单向流动
* 层级之间依赖关系需要简单、清晰，也就是所谓的强内聚、松耦合

单向数据流在前端也越来越流行，从 react 渲染到 flux 分层架构都是如此。单向数据流的优势在于这种数据结构流动是非常简单、清晰的，非常利于理解。下面的结构，大家基本上一看就能理解。

 ![](https://iling.fun/api/files.get?sig=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1cGxvYWRzL2MyMzFmODM0LTY3ZGQtNGJjZi1iM2RmLWYxMTQ2NGUxM2Y0MS8xOWFjZWQ3Yy02YzAzLTQ0ZmItOGE2OS1kZDFhNDg3N2MyMjQvaW1hZ2UiLCJ0eXBlIjoiYXR0YWNobWVudCIsImlhdCI6MTc5MDQzNzUwMCwiZXhwIjoxNzkwNTIzOTAwfQ.AQDQ8BCeNg7Q8FC983zCi_H9XPSQeihSKBwX8eB4U9I) ![](https://iling.fun/api/files.get?sig=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1cGxvYWRzL2MyMzFmODM0LTY3ZGQtNGJjZi1iM2RmLWYxMTQ2NGUxM2Y0MS8zZTBlYzk4OS05YWFkLTRlMGUtOTM5MC0xYzcyMjk2OTdkMGIvaW1hZ2UiLCJ0eXBlIjoiYXR0YWNobWVudCIsImlhdCI6MTc5MDQzNzUwMCwiZXhwIjoxNzkwNTIzOTAwfQ.gr2Ji0_jqY_NPwtI-2u9UDkpkTISx52oA9TqWwPq2tY)

小钱袋的应用架构遵循着这么几个标准

* 标准化，每一层逻辑标准化，层级之间保持隔离
* 灵活
* 类型完备

这个标准，我们具体看在后面讲怎么实现的。这里小钱袋应用架构图  ![](https://iling.fun/api/files.get?sig=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1cGxvYWRzL2MyMzFmODM0LTY3ZGQtNGJjZi1iM2RmLWYxMTQ2NGUxM2Y0MS82MWVhZDU5Yi1lNjBkLTQwYjYtOWRmZC1hZDk3YTA2MjFlZmMvaW1hZ2UiLCJ0eXBlIjoiYXR0YWNobWVudCIsImlhdCI6MTc5MDQzNzUwMCwiZXhwIjoxNzkwNTIzOTAwfQ.nrfXUtb-bVQ8aVBWFL3EfhAT_BImF5hbiDWPYr0MZ5Y)

数据是从上往下，或者从下往上的单向流动。服务进行分组，同一层级依赖也是单向的，尽量减少相互交叉依赖。

下面依次看每个层具体如何设计的。

### 接入层

接入层是最标准化的一层，与其说是写代码，不如说是写配置。

```typescript
import UserService from 'service/biz/user';
import { inject } from 'egg-di';
import BaseController, { LogType } from 'common/rpcBase';

export default class UserController extends BaseController {

  @inject() readonly userService: UserService;

  @bizLogger(LogType.CREATE_USER, { transaction: true })
  async createUser(payload: unknow) {
    const params = this.validate(payload, [ 'nick', 'gender', 'birthday' ]);
    const data = await this.userService.createUser(params);
    return { result: data, bizId: data.userId };
  }
}
```

函数最开头一个装饰器，两个入参，第一个配置了日志枚举，第二个定义了 MySQL事物控制开启。隐藏的还附带了一层逻辑，异常处理，通过这个装饰器装饰，会把函数包裹到一层 try catch 中。这个 try catch 中我们可以增加很多业务属性的逻辑的，包括

* 异常标准化
* 日志处理
* 事务控制

 ![](https://iling.fun/api/files.get?sig=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1cGxvYWRzL2MyMzFmODM0LTY3ZGQtNGJjZi1iM2RmLWYxMTQ2NGUxM2Y0MS8zNDVhYTUyYy0wYzJmLTRhYjAtYWE4MC04ZWZiZmM0MWJiZjAvaW1hZ2UiLCJ0eXBlIjoiYXR0YWNobWVudCIsImlhdCI6MTc5MDQzNzUwMCwiZXhwIjoxNzkwNTIzOTAwfQ.vFKi4vAMAoIA9RA5Slg_Sjaxtzf3DGmmV1BeteYzIeY)

接入层 base 处理基本逻辑如下流程图

这几个事情实际上需要也最应该在接入层统一处理了，有了明确异常处理，我们可以记录这个请求的结果是成功还是失败。对于失败，也可以区分已知异常还是未知异常，对于未知异常可以进行业务报警。

有了统一的异常处理，在其他层中，可以更放心的把异常抛出来。在 service 层，我们可以遵循 **所有 service 方法只返回成功结果，异常信息通过自定义异常抛出** 的标准。遵循这样的原则，对于减少思维负担很有用的。比如我们写鉴权逻辑

```typescript
  /**
   * 是否是小孩家庭的户主
   */
  async checkIsMaster(userId: string, guardianId: string) {
    // 是否是小孩的家长
    const guardian = await this.checkIsGuardian(userId, guardianId);
    // 是否是户主
    if (!guardian.isMaster) {
      throwCustomError(ERROR.E_NOT_MASTER);
    }
    return guardian;
  }
```

如果一个函数同时返回成功和失败两种数据，那么这对于使用者是很麻烦的事情，如果当前层无法处理的异常，需要把异常一层层手动抛出去。同时，这种结构在 ts 中是很难写类型描述的。后来我们慢慢全部改成了异常直接抛出的方式，这种方式更适合在应用内部之间处理异常。对外接口，我们可能需要把结果包装为成功和失败两个不同的状态。

如果后续需要处理异常，在这里可以开启事务，后面可以自动化处理事务提交和回滚逻辑。业务代码开发过程，就可以完全忽略事务存在了。

这里单独记录日志，可以准确区分系统异常和业务自定义异常。对于系统异常，这种属于非常严重的问题，我们可以加入到报警系统。另外，有了统一的日志，可以在 xflush 上做一个业务日志报表。

### 业务层 & 基础层

这两层，就是 chair 文件结构中的 service 。这里每个框就是一个文件夹，一开始我们就全部放在 service 目录里面，打平结构，可以说只有业务层。

 ![](https://iling.fun/api/files.get?sig=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1cGxvYWRzL2MyMzFmODM0LTY3ZGQtNGJjZi1iM2RmLWYxMTQ2NGUxM2Y0MS80YTZlMGNiNC01NDZkLTQ1YmQtYmQ2NS0zYTU4YmJhOTU5MGQvaW1hZ2UiLCJ0eXBlIjoiYXR0YWNobWVudCIsImlhdCI6MTc5MDQzNzUwMCwiZXhwIjoxNzkwNTIzOTAwfQ.OY42NZ24A4UAgtdyc_A3MujxYofiWqETiATV6rE72cs)

分层的核心在于数据单向流动，服务之间尽量少的相互依赖。一开始小钱袋我们在服务层经常会出现循环依赖的情况，这样会导致整体结构混乱，服务之间还是交叉、混杂在一起。有了隔离，但是隔离做的还不够。

分层不只是目录结构更清晰，更重要的是内在逻辑结构的清晰，这样看代码的可以一眼理解系统逻辑，写代码的时候尽量有据可依，不需要太纠结新的代码放哪里，有了这样的结构，多人合作才不会混乱。如果新加代码放哪里这个问题是模糊的，很容易出现同一份代码多处实现，有多种使用方式的情况。这非常不利于后续的维护，碎片化的代码多了，代码就开始腐化了。

小钱袋一开始做的时候，是没有领域层概念。当我们慢慢把用户服务中公共部分抽离出来，我发现用户服务会被所有业务层依赖，但实际上依赖的方法并不多。这几个方法抽离出来，就可以构成 user 的领域服务了。这样业务层之间不再需要相互依赖，user 领域服务层下沉。这样一改结构清晰多了。大致变动如下

一开始是相互交叉依赖，这个图是已经被我简化过很多的依赖关系，实际依赖关系图要复杂得多

第一次改进，把 User 拆分成 BizUser 和 UserDomain ，依赖关系一下子清晰多了，我们可以进一步优化，把 Trade 拆分

Trade 拆分后，依赖会更加清晰，不过线还是很多，但都是单向流动，就不至于乱，我们也可以直接抽象成分层之间的依赖 Biz --> Domain

小钱袋这样规模的应用，领域层并不是必须的，我们参考了领域驱动设计的思想，但并不完全基于此实现。

业务层和领域层的区别没有那么明显，关键是是否会形成循环依赖。简单业务情况下，可以直接写业务层，更加简单灵活，比如上面 User --> Diary 的依赖 。当一个服务被多个服务依赖的时候，需要把业务层和领域层分开，否则会业务之间会耦合在一起，越来越难以维护。

### 数据层

 ![](https://iling.fun/api/files.get?sig=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1cGxvYWRzL2MyMzFmODM0LTY3ZGQtNGJjZi1iM2RmLWYxMTQ2NGUxM2Y0MS81MjVkM2YyZC1hZTVhLTQ2MTMtOTk0NC1mNDVjNDhiNDYxMzIvaW1hZ2UiLCJ0eXBlIjoiYXR0YWNobWVudCIsImlhdCI6MTc5MDQzNzUwMCwiZXhwIjoxNzkwNTIzOTAwfQ.g-an0UQxWOCQrpINmsKhLvrbFurp9HrrQbUvWwdXIKs)

数据层主要处理数据库相关逻辑，每个数据表对应一个 dao(Data Access Object)。数据层封装了两个 Base

* BaseDAO 负责处理基础的日志、简单 crud 封装、事务控制、分表相关逻辑
* CacheBaseDAO 缓存相关逻辑

对于开发者，我们大部分时候只需要写简单的 BizDAO ，基本结构如下

#### BaseDAO

主要负责处理逻辑

* 数据结构转换，保证输出的结构都是和标准 do(Data Object) 对象
* 日志、简单 crud 封装、事务控制、分表相关逻辑

mysql 是关系型数据库，所以只能表示二维结构，在 ts 对象中，会包含更多数据结构。我们的数据库要求数据表命名必须是蛇形(snake case)命名，在 ts 里面，我们用的都是驼峰命名，数据流入数据库之前，需要做一次 deconvert ，key 转换为蛇形，反之亦然。

基础的 create、update、read(query/queryAll)、delete(update) 方法，而且都和泛型 T 保证关系。这样 BizDAO 写的所有方法，基本上都可以有完备的类型。

**类型完备分为两个流程，数据流出和数据流入**，流出入口是在 BaseDAO ，从这里出去的数据，全部都是 do 对象，通过手动写接口描述文件。数据流入，来自 controller ，经过统一的 `validate` 方法处理，得到过滤后的合法的数据结构。

 ![](https://iling.fun/api/files.get?sig=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1cGxvYWRzL2MyMzFmODM0LTY3ZGQtNGJjZi1iM2RmLWYxMTQ2NGUxM2Y0MS9kNGE3ZjdkYi0wODgxLTQ5OTQtYmI0My05YTE1MWFjNjFhNWMvaW1hZ2UiLCJ0eXBlIjoiYXR0YWNobWVudCIsImlhdCI6MTc5MDQzNzUwMCwiZXhwIjoxNzkwNTIzOTAwfQ.W0GBmIpwi-H1EdurvxwwURztqKFGhbRzEHBvR1jISnY)

从 controller 过来的数据都是 string ，如果我们不小心处理，很容易当成 number 来用了，这种在 js 中有时候会给你意想不到的惊喜。

日志实现也很简单，所有的 sql 都经过 base ，我们只需要对 sql 查询方法进行一次代理。记录 sql 日志，我们可以做一个 sql 报表了，这本应该是 zdal 提供的逻辑，但是 chair 版本的 zdal 有点太简单了。

 ![](https://iling.fun/api/files.get?sig=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1cGxvYWRzL2MyMzFmODM0LTY3ZGQtNGJjZi1iM2RmLWYxMTQ2NGUxM2Y0MS9iODM1ZGFkYi0xY2M3LTRhMWMtYmUzNS00MWU5MzA1NmEwZGYvaW1hZ2UiLCJ0eXBlIjoiYXR0YWNobWVudCIsImlhdCI6MTc5MDQzNzUwMCwiZXhwIjoxNzkwNTIzOTAwfQ.eNVmtQVmZpiUaTXfDw3NS9HcJIYQvl3tHZxoL7Nilwo)

有了这个报表，可以对 sql 进行更细致的业务报警处理，比如小钱袋处理的

* sql 查询超过 300ms 报警
* 对查询量最大的 sql 加上缓存层，提高性能优化
* 在分表业务的时候，可以定制一个分表切流大盘，观测分表流量是否正常

对于大流量业务，监控是强需求。没有监控，后续的优化无法做，做了也无法评估。

事务控制和接入层相呼应，接入层控制开关，开启事务逻辑后，在 base 层代理使用事务连接进行查询，两边都在 base 层中处理，对于业务开发来说，基本上不需要考虑事务。

## 踩过的坑

最后总结一下小钱袋应用遇到过的一些故障，服务端的问题相对来说比较少，大部分异常都和数据库相关。这里主要说下三个比较典型的问题

### 数据库性能

小钱袋最开始上线的时候没有做压测，业务量一直很小，问题并没有暴露。有一次运营在腰封推广了 1000w 余额宝活跃用户，然后服务器直接挂掉了。数据库服务器直接 cpu 慢了，导致所有的请求都开始堆积，业务基本上挂了半个小时。

问题原因就是没有命中索引，导致每次查询都需要扫描几十万行代码，耗时 100ms 左右。这在业务量小的情况，问题并没有暴露。后来我们加上了 MySQL 监控，并且在 MySQL **查询超过 20ms 的情况，就要开始排查问题了**。

后来我们结果几次压测，MySQL **命中索引的情况，性能是非常好的**。最高集群压到 1w qps ，数据库毫无压力，可以继续压，不过八台应用服务器已经有点扛不住了。小钱袋也完全达不到那么的业务量，就没有继续压测了。

### 业务唯一性保证

我的经验，**唯一索引是唯一值得信赖的**。业务逻辑总是可能会有漏洞，最终只能数据库兜底，来保证业务的唯一性。

小钱袋遇到一个故障是子卡重复问题，这个问题本质上是业务唯一性失效导致的。

业务上，每个小孩对应一个余额宝子卡，资金存在余额宝子卡中，我们有两个表，一个记录小孩，一个记录子卡信息。有一段时间，有一部分用户最多只能开一个子卡，当时我们在业务逻辑上保证了，一个用户只能创建一个小孩。

但漏掉了一个非常小的场景，导致这个逻辑失效，一个人可以同时创建三个子卡，但是子卡创建的时候，余额宝返回了同一个子卡。这样，三个小孩用同一个资金账号，最终影响了线上四百多个用户。这是我们距离资损最近的一次故障了。

这种场景如果有加小孩 id 和子卡号唯一索引，那么这个用户只会开户失败，不会出现数据错误，这种错误在金融领域有可能导致严重的资损，一定要非常小心。

### 金额不对等的问题

小钱袋多人转入过程，实际上是 A 转给 B ，然后 B 转入子卡，两个流程。上线初期，我们遇到过 A 转给 B 0.88 元，到第二个步骤，变成 B 转入子卡 0.89 元的故障。

因为在第一步只冻结了 0.88 ，到第二个流程自然就失败了。收到了线上解冻失败报警后，排查了一下发现问题是这样的

在一个活动场景，用户输入了 0.888 的金额，在第一阶段，这个金额被转账处理成了 0.88 。同时数字传递到小钱袋服务器，小钱袋并没有过滤处理，直接给了 MySQL 数据库，数据库字段是 `decimal(15,2)` ，自动四舍五入变成了 0.89 。

这个问题解决很简单，这种 0.888 的金额，应该做一层服务端拦截的，否则还是很容易出现问题的。后来看 Java 应用，一般金额用 int 存，单位为分，而不是元，这样处理更合理。

## 总结

小钱袋应用首先选择了 TypeScript 作为服务端开发语言，并且逐步完善类型体系，尽量做到应用整体类型完备。

经过一段时间发展，业务层越来越复杂，在尝试对业务层进行分组的时候，我们选择了使用依赖注入的方式，做到服务之间可见性可控。慢慢在业务层中抽象一些领域服务下沉到基础服务中，最终实现业务层和基础层数据单向流动的结构。

小钱袋应该规模相对于 Java 应用来说，还是非常简单的应用。小钱袋本身也是在逐步发展、演变成现在这样，还是有很多不完善的，感觉整体上参考 Java 是一个比较合理的方向。

---

**Documents**

- [基于实践谈谈软件架构设计](https://iling.fun/s/blog/doc/5z65lqo5a6e6le16lci6lci6l2v5lu25p625p6e6k66k6h-tsGrQnNSNu)
- [rspack 使用体验过程](https://iling.fun/s/blog/doc/rspack-u8S4Mtjkfz)
- [E2E test with Playwright](https://iling.fun/s/blog/doc/e2e-test-with-playwright-JLFKAvWQ0U)
- [Vim everywhere](https://iling.fun/s/blog/doc/vim-everywhere-v6WZyLzGA1)
- [滴答 FaaS 研发平台小结](https://iling.fun/s/blog/doc/faas-XQxhEO3e5r)
- [Cod 服务编排之路](https://iling.fun/s/blog/doc/cod-0CFsOfiX20)
- [Cod 起源和用武之地](https://iling.fun/s/blog/doc/cod-1aAA2rVJZY)
- [2019 年度总结](https://iling.fun/s/blog/doc/2019-RhbTD2SiCn)
- [凤蝶区块历史及其问题排查记录](https://iling.fun/s/blog/doc/5yek6j225yy65z2x5y6g5yy5yk5yw26zeu6aky5o6s5pl6k6w5b2v-WoaaJ9QHpq)
- [如何用单元测试](https://iling.fun/s/blog/doc/5aac5l2v55so5y2v5ywd5rwl6kv-3zwG1cJvTn)
- [小钱袋 Node 应用开发经验总结](https://iling.fun/s/blog/doc/node-QpFfYCuoE0)
- [简单 JS 算数表达式求值算法的实现探索](https://iling.fun/s/blog/doc/js-FXcWkpW2vw)
- [凤蝶可视化编辑实现原理](https://iling.fun/s/blog/doc/5yek6j225yv6keg5yyw57yw6l6r5a6e546w5y6f55cg-ZsBj0CS1ev)
- [凤蝶服务端渲染经验总结二](https://iling.fun/s/blog/doc/5yek6j225pyn5yqh56uv5riy5pt57up6aqm5oc757ut5lqm-a4Kd59d7Td)
- [凤蝶服务端渲染经验总结](https://iling.fun/s/blog/doc/5yek6j225pyn5yqh56uv5riy5pt57up6aqm5oc757ut-GBhTESymbn)
- [Node.js 中 sourcemap 使用问题总结](https://iling.fun/s/blog/doc/nodejs-sourcemap-duex3wDhWt)
- [如何实现一个 mvvm 组件](https://iling.fun/s/blog/doc/mvvm-8TXAVSXe77)
- [webpack 热加载原理探索](https://iling.fun/s/blog/doc/webpack-7Wjn9z1fUB)
- [再谈 vim](https://iling.fun/s/blog/doc/vim-UBf5u57ko7)
- [How to realize velocity template interpreters](https://iling.fun/s/blog/doc/how-to-realize-velocity-template-interpreters-4s1KEznjYR)
- [joycss](https://iling.fun/s/blog/doc/joycss-bGvBvDGF7k)