工作回顾之账单号怎么重复了?

一、引言

不管是在电商系统、还是物业系统中都会存在一个账单的东西,账单号是每一个账单的标识,这个标识是唯一的、且不可重复的。

在电商系统中,账单号就等同于订单号;在物业系统中,账单号就是每一次向业主催缴费的账单。

这个账单号一旦重复结果可以说是灾难级的,在物业系统中,收费的时候,明明不是这个业主的钱,怎么多算了;而另一个业主的钱甚至少算,或者该缴的费用被其他业主缴了等等,涉及到钱的问题那就是大问题了。

这篇文章,我们就来具体分析下这个问题,并给出解决方案。

二、发现问题:从用户反馈到技术排查

2.1 来自用户的反馈

在某个阳光明媚的上午,我正在工位前快马加鞭的完成今天的工作任务,突然一个身影“不合事宜”的出现在了我的旁边,我转头一看原来是客成(客户成功团队:负责项目咨询、问题反馈,相当于售后了)同事

:“咋了?”

:“客户那边反馈有个问题,你帮忙看一下呗”

:“啥问题”

:“客户说自己的业主有笔账单,本来一个月的物业费应该是321块,结果翻了双倍,变成了600多”

我眉头紧锁,心里想:这啥情况啊,钱还会算错?!

一看是钱的问题,我就在心里默默地把优先级排到了最高!(今天的工作事项?你可去一边儿等会儿吧)

我们负责的物业系统是多租户模式的,如果有一个租户出现了问题,那么其他租户也可能有相同的问题发生

:“好,让客户先提个工单过来,描述下具体问题,我再看一下”

一句话把同事先“打发”走,没过一会儿,企微上消息甩了过来,是刚刚聊的问题已经建立了工单,我立刻流转了状态,进入到问题排查中...

2.2 问题定位

排查问题三步走

一看业务代码:如果只看业务代码就能看到问题所在,那说明这个问题很容易就能解决

二看数据:通过查看业务代码,把账单费用计算的逻辑知晓,然后自己再计算一遍最后的金额。我自己算了一遍之后发现金额确实不对,把相关的SQL整理出来,甩到数据库客户端工具中(我最常用的还是Navicat Premium了)

不查不知道,一查吓一跳,一个账单号在账单主表中重复出现了两次?而且业主id竟然不是同一个人(啥情况?我的大脑差点宕机... )再看账单号字段,竟然没配置唯一索引?

三问老大:“*哥,这个账单表中的账单号字段为啥没加唯一索引啊?!”

:“嗯...”

经过漫长的嗯之后:“可能是历史遗留问题了,你自己看下解决一下啦”

又是历史遗留问题,这个项目的历史问题可太多了

回到自己的工位上,把生成账单的代码又仔仔细细看了一遍,感觉有点猫腻啊!伪代码如下:

// 获取code
public function getCode()
{
    $redis = new Redis();
    $billCode = '';
    
    try {
        if (empty($redis->get('bill_code'))) {
            $redis->set('bill_code', 1);
            $lastBillCode = $this->getLastBillCode();
            $redis->clear('bill_code');
            return $lastBillCode++;
        } 
        throw new Exception('生成code失败!');
    } catch (\Exception $e) {
        throw new Exception('生成code失败!');
    } 
}

// 获取账单表中最后一条记录的code
public function getLastBillCode()
{
     return $PDO->select("select code from bill order by datetime DESC limit 1");     
}

上面的代码不仔细看还真看不出什么问题,但是好在我多年的经验练就了慧眼如炬,问题基本上清晰了。这段代码要实现逻辑其实很清晰

  1. 通过redis获取指定key

  2. 如果指定key不存在,设置指定key为1

  3. 获取最后一笔账单的code,自增+1返回

  4. 如果指定key已被设置,直接返回失败

这段代码不难理解为什么要这么写,应该是当时的开发者想要为当前生成账单code的行为设置一个锁,直到code生成成功,再把这个锁释放掉。这样如果有其他进程也想生成code,那再进来的时候先判断指定key是否被设置,如果已被设置,则返回失败。

这样的操作在大部分情况几乎没啥问题,但是在高并发的情况下,很容易就产生重复的code,因为查询和设置的动作不是原子性的;

既然问题已经确定了,那解决起来就很容易了。

以这个场景为例,其实我们需要的就是一个分布式锁了。

三、分布式锁

什么是分布式锁?

分布式锁是一种在分布式系统中(多节点、多进程、多服务)中协调共享资源的访问机制,确保在任一时刻只有一个客户端(节点)能访问资源(如数据库、文件、服务等),从而避免数据竞争和不一致问题。

使用场景

  • 防止重复操作,例如:用户提交订单,确保订单只处理一次

  • 库存扣减,避免超卖:多个服务同时扣减库存,保证原子性

  • 定时任务调度,在分布式系统中,确保集群中只有一个服务能执行定时任务(如每天的数据统计、库存刷新等)

  • 分布式缓存更新,多个节点同时更新缓存,避免脏数据

特性

互斥性

同一时间,只有一个客户端可以持有锁

防死锁

即使客户端崩溃,到了指定时间也能释放(通过超时机制)

高可用

锁服务需具备容错能力(如redis集群)

四、解决问题

那了解了什么是分布式锁,那基于我们这个场景,其实就很适用了。生成账单code,同一时间只能有一个生成账单的服务运行。那redis中是如何实现呢?

首先先了解redis的两个命令

redis两个命令

NX

NX全称Not Exists,表示只有键不存在时,才执行操作,比如设置值。
用法:实现分布式锁,或避免覆盖已有数据。
举例:

SET key "value" NX
  • 如果key不存在,则设置key值为value,返回OK

  • 如果key存在,则操作失败,返回nil

PX

PX即毫秒级过期时间,作用是为键设置毫秒级的过期时间(TTL

redis设置过期时间命令的不只有PX,还有EXEX设置秒级过期时间,PX设置毫秒级过期时间(10000毫秒为10秒)

举例:

SET key "value" PX 10000  # 10秒后自动删除

结合来用

NX + PX 的组合常用于带超时的分布式锁

SET lock:resource "value" NX PX 10000

以上代码表示如果锁lock:resource不存在,则设置值,并10秒后释放。如果锁已存在,则获取锁失败。

那在php中如何使用呢,参考以下代码

$redis = new Redis();
$redis->connect('127.0.0.1', 6379); // 连接 Redis

$script = 'return redis.call("SET", KEYS[1], ARGV[1], "NX", "PX", ARGV[2])';

$result = $redis->eval($script, ['lock_create_code', 1, 5000], 1); // 最后一个参数是 KEYS 的数量
if ($result === false) {
    throw Exception "获取锁失败!~";
}

// 执行业务逻辑
echo "生成订单code中...\n";
sleep(2);

// 释放锁(需校验value)
$unlockScript = '
    if redis.call("GET", KEYS[1]) == ARGV[1] then
        return redis.call("DEL", KEYS[1])
    else
        return 0
    end
';
$redis->eval($unlockScript, ['lock:create_code', 1], 1);

redis会单线程执行整个脚本,所以不用担心并发问题。

五、总结

好啦,以上就是遇到生成订单号重复的解决方案,可能也有更优的解决方案,但是目前这个方案个人感觉是最适合这个业务场景的。

添加新评论