一、背景
随着O2O线上线下业务的不断扩展,电商平台也在逐步完善交易侧相关的产品功能。在最近的需求版本中,业务方为进一步提升用户的使用体验,规划了取货码生成及订单核销相关逻辑,目的是让线上的用户在付完款之后能够到店取货或者安排导购派送。
取货码使用起很简单,然而像“冰山”一样,隐藏在简单外表下面却需要严谨的设计和细致的逻辑,可以说麻雀虽小五脏俱全。本文介绍的设计也比较有趣,而且按此思路可以实现市面上大多数核销类券码的生成,同时也能满足业务的SaaS化,算是一个相对通用的能力,在此把整个设计分享给大家。
取货码长度相对较短,比起动辄十几二十位订单号,几位的数字码更方便记忆和输入;
二、简单系统的单表业务
未核销状态的数字码不重复即可,也即已核销的数字码可以回收利用。
伪代码实现
for (;; {
step1 获取随机码:String code = this.getRandomCode(;
step2 执行SQL:SELECT COUNT(1 FROM order_main WHERE code = ${code} AND write_off_status = 0;
step3 判断是否可以插入:if ( count > 0 { continue; }
step4 执行数据写入:UPDATE order_main SET code = ${code}, qr_code = ${qrCode}, write_off_status = 0 WHERE order_no = ${orderNo}
}
*注意:这里step2和step4不是原子操作,存在并发问题,实际应用中***使用分布式锁,把操作锁住。
三、 复杂平台的分库分表业务
B端和C端用户的体验非常重要,服务端接口的设计需要充分考虑鲁棒性,完善最基本的重试及容错能力;
不同业务方对于取货码的要求可能不太一样,取货码的设计需要具有通用性以及个性化的配置属性。
SaaS 产品涉及的店铺很多且订单量大,需要设计大容量存储,所以订单表基本使用分库分表,显然作为订单附属的取货码表也得使用相同的策略;
3.1 详细设计
方便利用同样的分库分表因子进行查询(例如:open_id、member_id)。
和订单一样,支撑海量订单行的存储;
3.2 门店唯一方案
刚开始考虑使用类似饭馆取餐码类似的逻辑,保证取货码在各自门店保持唯一就行了。类似如下图交互,图中用户A和用户B持有相同的取货码,用户A、B分别去他们对应的店铺完成核销,整个交易过程就结束了。但是这得保证用户A和B能正确地在各自订单归属的店铺完成核销,显然这个方案是带有风险的!
3.3 全局唯一方案
步骤①: 可以将8位的取货码分成两个区域,“随机码区域”+“库表位置”,下图示例:
这里也有两套方案:
【方案一】可以选择2位库表的首位作为库编号,末位作为表编号。好处是映射较为简单,但是容量不够大,如果分的库或表>9,扩展就会有点麻烦。如下图,我们把末尾“12”逻辑映射到了“1库的编号为2的表”;
【方案二】将4库4表二维结构转成一维,以0为初始值进行递增,(0库, 0表 → 00, (0库, 1表 → 01... , (3库, 3表 → 15。好处是容量变大了,最大支持99张表,不受库或表单一条件的限制,缺点就是映射逻辑写起来麻烦点,不过这不是问题。
3.4 方案落地遇到的问题
既然本篇是介绍SaaS化的完整方案,在落地的时候或多或少会遇到一些问题,这边介绍三个实际遇到的典型问题,并给出一些解决方案:
【解决】其实重复的情况有两种:
另外一种情况就是别的事务在正在操作,正好有个分布式事务锁住了一样的数字码(概率很低,但是是有可能的)。
可能是表里已经存在数字相同未核销的取货码;
// step1 根据分库分表因子获取库表编号,userCode-用户编号、tenantId-租户编号 String suffix = getCodeSuffix(userCode, tenantId; // step2 批量获取6位随机码 for (int i=1; i<=5; i++ { // 批量获取随机数。每次重试,取2的指数级量进行过滤,相比暴力执行for循环,这种方式能减少和DB的交互 List<String> tempCodes = getRandomCodes(2 << i; // 过滤掉分布式锁 filterDistributeLock(tempCodes; // 过滤掉数据库存在的随机码 filterExistsCodes(tempCodes; return tempCodes; } // step3 处理随机码,随机码入库 for (String code : codes { // 加锁,判断加锁是否成功。推荐使用Redis分布式锁 boolean hasLockd = isLocked(code; try { // 执行入库 insert(object; } finally { // 解锁 } } // step4 执行后置二维码图片等逻辑
【注意】
建议数字码生成完毕后加锁并执行INSERT,生成图片地址等耗时严重的动作可以后置UPDATE上去。
推荐使用指数级重试的方式(2 << i),逐次递增random的数量,减少和DB的交互;
【解决】我们以ShardingSphere-JDBC作为为案例来给出一些配置及伪代码,具体可以参考:《强制路由::ShardingSphere》,其他开源的分库分表组件或者自研产品不做赘述,可以自己手动写个插件,别怕,即使再难,也要相信有光!
// ShardingSphere-JDBC依赖的配置文件jdbc-sharding.yaml ... shardingRule: tables: ... # 取货码表 order_code: actualDataNodes: DS00$->{0..3}.order_pick_up_0$->{0..3} # 配置库的计算逻辑 databaseStrategy: hint: algorithmClassName: com.xxx.xxxxx.xxx.service.impl.DbHintShardingAlgorithm # 配偶之表的计算逻辑 tableStrategy: hint: algorithmClassName: com.xxx.xxxxx.xxx.service.impl.DbHintShardingAlgorithm ... // java代码 try (HintManager hintManager = HintManager.getInstance( { hintManager.addDatabaseShardingValue("order_code"/** 取货码表 */, DbHintShardingAlgorithm.calDbShardingValue(tenantId, code; hintManager.addTableShardingValue("order_code"/** 取货码表 */, DbHintShardingAlgorithm.calTabShardingValue(tenantId, code; Object xxx = xxxMapper.selectOne(queryDTO; }
【注意】
第一条说了比较灵活,体现在自己实现的 “DbHintShardingAlgorithm.calDbShardingValue(tenantId, code” 方法上,这个方法可以自己定义,所以我们的入参可以是通用的分库分表因子,也可以是自定义的取货码的“库表位置”字段,非常灵活。
这里介绍一种编程式的解决方案,好处是配置简单、比较灵活,缺点就是代码稍微多一点。其实ShardingSphere还支持注解的方式,可以自己研究下;
【解决】细心的小伙伴应该注意到了 "tenantId" 这个字段,这是个租户的编码,在实际编码会进行透传。我们可以利用这个字段针对不同的租户(或叫业务方)来做不同的配置,比如:取货码的长度、取货码编排的方式、取货码映射库表位置的策略等等做成可配,只要把主干逻辑进一步抽象,并使用策略模式进行个性化编码。