PAYATHON 2026

使用 Database 还是 flat file 查询 zipcode?

支付老李

结论

优先使用数据库,把“快递公司、支付方式、zipcode”建模为多对多关系。不要为每家快递公司添加 ABC_COD、DEF_COD 这样的 boolean column。

这类数据通常只有几千到几万条,对于建有合适 index 的数据库来说很小。索引查询一般不会成为性能瓶颈。与多个 flat file 相比,数据库也更便于扩展、修改和去重,并能更好地处理并发访问和数据一致性。

建议让每条记录表示一种明确的支持关系,即“某家 courier company 可以通过某种 payment method 服务某个 zipcode”。

为什么不建议使用多个 flat file

flat file 并不是完全不可行。如果列表固定、只有一个进程读取,而且应用启动时会把全部数据加载到内存,那么通过 HashSet 查询会很快,平均时间复杂度接近 O(1)。

但这些数据以后需要维护,courier company 也会增加。随着业务变化,文件方案会带来不少额外工作:

  • 每增加一种组合,都要新增文件或调整文件结构。
  • 更新文件时,需要处理并发读取、原子替换和缓存失效。
  • 重复数据、格式错误和规则冲突很难统一控制。
  • 多台应用服务器必须保持文件版本一致。
  • 查询“哪些公司支持这个 zipcode”时,需要搜索多个文件。
  • 后台管理、批量导入、审计和规则启停都要单独开发。

如果每次 checkout 都要读取文件、解析内容,再执行 binary search,实际性能可能比数据库查询更差。开销主要来自文件 I/O 和解析,而不是 binary search。

即使决定把数据放进内存,精确匹配时也通常应优先使用 HashSet,而不是 array 加 binary search。HashSet 更直接,也不需要维护排序。

推荐的数据结构

可以使用三张基础表和一张关联表。

CREATE TABLE courier_company (
    id          BIGINT PRIMARY KEY,
    name        VARCHAR(100) NOT NULL UNIQUE,
    is_active   BOOLEAN NOT NULL DEFAULT TRUE
);

CREATE TABLE payment_method (
    id          SMALLINT PRIMARY KEY,
    code        VARCHAR(30) NOT NULL UNIQUE
);

CREATE TABLE serviceable_zipcode (
    courier_id         BIGINT NOT NULL,
    payment_method_id  SMALLINT NOT NULL,
    zipcode            VARCHAR(20) NOT NULL,

    PRIMARY KEY (courier_id, payment_method_id, zipcode),

    FOREIGN KEY (courier_id)
        REFERENCES courier_company(id),

    FOREIGN KEY (payment_method_id)
        REFERENCES payment_method(id)
);

示例数据:

INSERT INTO payment_method (id, code)
VALUES
    (1, 'COD'),
    (2, 'NET_BANKING');

INSERT INTO courier_company (id, name)
VALUES
    (1, 'ABC'),
    (2, 'DEF');

INSERT INTO serviceable_zipcode
    (courier_id, payment_method_id, zipcode)
VALUES
    (1, 1, '560001'),
    (1, 2, '560001'),
    (2, 1, '110001');

增加 courier company 时,只需插入公司信息及其支持范围,不需要修改 table schema。

如果支付方式是固定枚举,也可以不创建 payment_method table,直接在关联表中保存受约束的字符串或数据库枚举值:

CREATE TABLE serviceable_zipcode (
    courier_id     BIGINT NOT NULL,
    payment_method VARCHAR(30) NOT NULL,
    zipcode        VARCHAR(20) NOT NULL,

    PRIMARY KEY (courier_id, payment_method, zipcode),

    CHECK (payment_method IN ('COD', 'NET_BANKING'))
);

是否使用数据库原生 ENUM,需要根据具体数据库决定。如果以后可能增加支付方式,普通 lookup table 通常更容易维护。

查询方式

检查指定公司是否支持某个 zipcode 和 payment method:

SELECT 1
FROM serviceable_zipcode
WHERE courier_id = ?
  AND payment_method_id = ?
  AND zipcode = ?
LIMIT 1;

应用只需判断记录是否存在,不必读取整条记录,也不用先查询数量。

如果要查找所有能处理该订单的 courier companies:

SELECT courier_id
FROM serviceable_zipcode
WHERE payment_method_id = ?
  AND zipcode = ?;

这条查询的条件顺序与主键顺序并不完全一致,因此需要添加适合该查询的 index:

CREATE INDEX idx_serviceable_zipcode_lookup
ON serviceable_zipcode (payment_method_id, zipcode, courier_id);

数据库可以一次返回所有符合条件的公司,不必先查 ABC,再查 DEF。应用拿到候选公司后,再根据价格、优先级或预计送达时间进行选择。

如果公司还有优先级,可以添加相应字段:

ALTER TABLE courier_company
ADD COLUMN priority INTEGER NOT NULL DEFAULT 100;

查询时通过联表完成排序:

SELECT c.id, c.name
FROM serviceable_zipcode AS sz
JOIN courier_company AS c
  ON c.id = sz.courier_id
WHERE sz.payment_method_id = ?
  AND sz.zipcode = ?
  AND c.is_active = TRUE
ORDER BY c.priority ASC;

zipcode 应使用字符串

zipcode 应保存为字符串,而不是整数。它是标识符,不参与数学运算。

使用 VARCHAR 可以避免这些问题:

  • 开头的 0 不会丢失。
  • 某些国家或地区的 postal code 包含字母、空格或连字符。
  • 将来扩展国际配送时,不必再修改字段类型。

如果系统明确只服务固定国家,而且格式始终是六位数字,可以使用 CHAR(6):

zipcode CHAR(6) NOT NULL

其他情况下,使用长度足够的 VARCHAR 更合适。写入和查询前还应统一规范化,例如清除首尾空格,并按业务规则统一大小写或分隔符。不要在每次查询时临时对 column 调用转换函数,否则可能影响 index 的使用。

查询性能是否足够

按当前规模计算,即使每家公司有 10,000 条记录,增加几十家公司后,数据量对常规关系型数据库来说仍然不大。只要查询条件与 index 匹配,数据库就能通过 index 定位记录,无需扫描完整列表。

具体响应时间取决于数据库类型、网络、连接池、磁盘和并发量,不能脱离部署环境直接判断。不过,仅从数据规模和查询形式来看,没有必要为了避开数据库查询而优先设计多文件系统。

应用与数据库之间的网络往返,有时会比索引查找本身更耗时。因此需要注意:

  • 使用 connection pool。
  • 一次查询所有可用 courier companies,不要在循环中逐家公司查询。
  • 避免 SELECT *。
  • 根据真实查询建立 composite index。
  • 使用数据库的执行计划工具确认是否命中 index,例如 EXPLAIN。

读取很多时可以增加缓存

数据库和内存查询并不冲突。比较实用的做法是让数据库承担权威数据源的角色,在确有性能需求时再添加 cache。

例如,可以在应用中维护以下映射:

(payment_method, zipcode) -> [courier_id, ...]

也可以按公司组织:

(courier_id, payment_method) -> HashSet<zipcode>

应用启动或缓存失效时,从数据库加载数据。checkout 查询先访问 cache。后台修改规则后,再通过版本号、事件通知或较短的 TTL 更新 cache。

这种方式既能获得内存查询的速度,也能保留数据库的可维护性。是否需要 cache,应根据监控和压测结果决定。几万条数据通常可以先直接查询数据库,不必一开始就引入缓存一致性的额外复杂度。

数据更新与批量导入

courier company 提供的范围列表通常适合批量导入。更新时应使用 transaction,以免客户查询到只更新了一部分的数据。

一种简单做法是先把新列表写入 staging table,验证格式和数量,再在 transaction 中替换正式数据:

BEGIN;

DELETE FROM serviceable_zipcode
WHERE courier_id = ?
  AND payment_method_id = ?;

INSERT INTO serviceable_zipcode
    (courier_id, payment_method_id, zipcode)
SELECT
    ?, ?, zipcode
FROM serviceable_zipcode_staging;

COMMIT;

生产环境中还要考虑大批量删除对锁、日志和复制延迟的影响。如果数据量较大,可以给数据集增加版本号。完整导入新版本后再切换当前版本,避免删除旧列表后出现短暂空档。

哪些情况下 flat file 更合适

当以下条件基本成立时,flat file 才可能是更简单的选择:

  • 数据随应用发布,运行期间不会修改。
  • 只有一个应用实例,或者已有可靠的文件分发机制。
  • 不需要后台管理、审计和复杂查询。
  • 应用启动时一次性加载文件,之后只查询内存。
  • 文件只是配置,不是需要持续维护的业务数据。

即便满足这些条件,也建议把多个文件整理成统一的结构化格式,不要按公司和支付方式不断增加文件。例如使用 CSV:

courier,payment_method,zipcode
ABC,COD,560001
ABC,NET_BANKING,560001
DEF,COD,110001

这其实就是关联表的文件形式。业务发展后,也能比较方便地导入数据库。

最终建议

使用数据库作为唯一可信的数据源,通过 normalized association table 保存 courier_id + payment_method + zipcode,并根据实际查询建立 composite index。不要把 courier company 编码成 boolean column,也不要让应用依次搜索每家公司的列表。

可以先实现带索引的存在性查询或候选公司查询,再根据真实流量观察性能。只有确认数据库访问已经成为瓶颈时,才需要加入应用内 HashSet 或集中式 cache。这样既能保持查询成本稳定,也方便以后增加公司、支付方式和管理功能,而不必修改 schema。

备注:内容仅供参考。