会社のビジネス ニーズにより、小規模な決済システムの実装には 2 週間かかりました。小規模ではありますが、アカウント ロック、トランザクション保証、会計照合などのさまざまな必要なモジュールが完全に実装されています。その過程で多くの経験を積んできましたが、インターネットで検索してもほとんど実用的価値のない研究論文が多かったので、今回は特別に公開します。
このシステムは、小規模な支払いシステムとして、またはサードパーティのアプリケーションがオープン プラットフォームに接続されている場合の支払いフロー システムとして使用できます。
元の要求はより責任のあるものです。少し単純化して次のように言います。
上記の要件に応えて、次のデータベースをセットアップしました:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 ログイン後にコピー |
CREATE TABLE `app_margin`.`tb_status` ( `appid` int(10) UNSIGNED NOT NULL, `freeze` int(10) NOT NULL DEFAULT 0, `create_time` datetime NOT NULL, `change_time` datetime NOT NULL, PRIMARY KEY (`appid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `app_margin`.`tb_account_earn` ( `appid` int(10) UNSIGNED NOT NULL, `create_time` datetime NOT NULL, `balance` bigint(20) NOT NULL, `change_time` datetime NOT NULL, `seqid` int(10) NOT NULL DEFAULT 500000000, PRIMARY KEY (`appid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `app_margin`.`tb_bill` ( `id` int AUTO_INCREMENT NOT NULL, `bill_id` int(10) NOT NULL, `amt` bigint(20) NOT NULL, `bill_info` text, `bill_user` char(128), `bill_time` datetime NOT NULL, `bill_type` int(10) NOT NULL, `bill_channel` int(10) NOT NULL, `bill_ret` int(10) NOT NULL, `appid` int(10) UNSIGNED NOT NULL, `old_balance` bigint(20) NOT NULL, `price_info` text, `src_ip` char(128), PRIMARY KEY (`id`), UNIQUE KEY `unique_bill` (`bill_id`,`bill_channel`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `app_margin`.`tb_assign` ( `id` int AUTO_INCREMENT NOT NULL, `assign_time` datetime NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `app_margin`.`tb_price` ( `name` char(128) NOT NULL, `price` int(10) NOT NULL, `info` text NOT NULL, PRIMARY KEY (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `app_margin`.`tb_applock` ( `appid` int(10) UNSIGNED NOT NULL, `lock_mode` int(10) NOT NULL DEFAULT 0, `change_time` datetime NOT NULL, PRIMARY KEY (`appid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; INSERT `app_margin`.`tb_assign` (`id`,`assign_time`) VALUES (100000000,now()); ログイン後にコピー |
詳細な説明は次のとおりです。
ライブラリ テーブルを設計したら、最も一般的な操作をいくつか見てみましょう。
1. 支払い操作
ここでは私が現在実装している方法のみを記載します。これが最善ではないかもしれませんが、最も経済的でニーズを満たすものであるはずです。
まず呼び出し元について説明します。ロジックは次のとおりです。
対応する支払いシステムの内部ロジックは次のとおりです (支払い操作のみがリストされ、ロールバック ロジックは同様で、フロー チェックは対応する支払いフローが存在するかどうかを確認することです):
一般的に使用されるエラー戻りコードは、次のように十分です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 ログイン後にコピー |
$g_site_error = array( -1 => '服务器繁忙', -2 => '数据库读取错误', -3 => '数据库写入错误', 0 => '成功', 1 => '没有数据', 2 => '没有权限', 3 => '余额不足', 4 => '账户被冻结', 5 => '账户被锁定', 6 => '参数错误', ); ログイン後にコピー |
而在支付系统内部,之所以采用先写入流水,再进行账户更新的方式也是有原因的,简单来说就是尽量避免丢失流水。
最后总结一下,这种先扣钱,再发货,出问题再回滚的方式是一种模式;还有一种是先预扣,后发货,没有出问题则调用支付确认来扣款,出了问题就调用支付回滚来取消,如果预扣之后很长时间不做任何确认,那么金额会自动回滚。
二. 账户锁定的实现
这里利用了数据库的加锁机制,具体逻辑就不说了,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 ログイン後にコピー |
class AppLock { function __construct($appid) { $this->m_appid = $appid; //初始化数据 $this->get(); } function __destruct() { $this->free(); } public function alloc() { if ($this->m_bGot == true) { return true; } $this->repairData(); $appid = $this->m_appid; $ret = $this->update($appid,APPLOCK_MODE_FREE,APPLOCK_MODE_ALLOC); if ($ret === false) { app_error_log("applock alloc fail"); return false; } if ($ret <= 0) { app_error_log("applock alloc fail,affected_rows:$ret"); return false; } $this->m_bGot = true; return true; } public function free() { if ($this->m_bGot != true) { return true; } $appid = $this->m_appid; $ret = $this->update($appid,APPLOCK_MODE_ALLOC,APPLOCK_MODE_FREE); if ($ret === false) { app_error_log("applock free fail"); return false; } if ($ret <= 0) { app_error_log("applock free fail,affected_rows:$ret"); return false; } $this->m_bGot = false; return true; } function repairData() { $db = APP_DB(); $appid = $this->m_appid; $now = time(); $need_time = $now - APPLOCK_REPAIR_SECS; $str_need_time = date("Y-m-d H:i:s", $need_time); $db->where("appid",$appid); $db->where("lock_mode",APPLOCK_MODE_ALLOC); $db->where("change_time <=",$str_need_time); $db->set("lock_mode",APPLOCK_MODE_FREE); $db->set("change_time","NOW()",false); $ret = $db->update(TB_APPLOCK); if ($ret === false) { app_error_log("repair applock error,appid:$appid"); return false; } return true; } private function get() { $db = APP_DB(); $appid = $this->m_appid; $db->where('appid', $appid); $query = $db->get(TB_APPLOCK); if ($query === false) { app_error_log("AppLock get fail.appid:$appid"); return false; } if (count($query->result_array()) <= 0) { $applock_data = array( 'appid'=>$appid, 'lock_mode'=>APPLOCK_MODE_FREE, ); $db->set('change_time','NOW()',false); $ret = $db->insert(TB_APPLOCK, $applock_data); if ($ret === false) { app_error_log("applock insert fail:$appid"); return false; } //重新获取数据 $db->where('appid', $appid); $query = $db->get(TB_APPLOCK); if ($query === false) { app_error_log("AppLock get fail.appid:$appid"); return false; } if (count($query->result_array()) <= 0) { app_error_log("AppLock not data,appid:$appid"); return false; } } $applock_data = $query->row_array(); return $applock_data; } private function update($appid,$old_lock_mode,$new_lock_mode) { $db = APP_DB(); $db->where('appid',$appid); $db->where('lock_mode',$old_lock_mode); $db->set('lock_mode',$new_lock_mode); $db->set('change_time','NOW()',false); $ret = $db->update(TB_APPLOCK); if ($ret === false) { app_error_log("update applock error,appid:$appid,old_lock_mode:$old_lock_mode,new_lock_mode:$new_lock_mode"); return false; } return $db->affected_rows(); } //是否获取到了锁 public $m_bGot = false; public $m_appid; } ログイン後にコピー |
为了防止死锁的问题,获取锁的逻辑中加入了超时时间的判断,大家看代码应该就能看懂
三. 对帐逻辑
如果按照上面的系统来设计,那么对帐的时候,只要对一下两边成功(即bill_ret=0)的流水即可,如果完全一致那么账户应该是没有问题的,如果不一致,那就要去查问题了。
关于保证账户正确性这里,也有同事跟我说,之前在公司做的时候,是采取只要有任何写操作之前,都先取一下流水表中所有的流水记录,将amt的值累加起来,看得到的结果是否和余额相同。如果不相同应该就是出问题了。
1 ログイン後にコピー |
select sum(amt) from tb_bill where appid=1; ログイン後にコピー |
所以这也是为什么我在流水表中,amt字段是要区分正负的原因。
OK,整篇文章写的很长,希望对坚持读完的同学有所帮助。