2024年4月


第1版设计初稿
发布预览后,大家的批评就是我们改进的动力。

我们抛弃了之前的设计,进行了更大胆的设计尝试,抛开了code,抛开了logo,让星星成为主角,让可爱有趣成为主调。

于是,出炉星星摸鱼系列设计,今天发布出来给大家预览。

欢迎继续批评吐槽。

星星摸鱼款1

星星摸鱼款2

星星摸鱼款3

星星摸鱼款4

写在开头

在很多的面经中都看到过提问
CountDownLatch
的问题,正好我们最近也在梳理学习AQS(抽象队列同步器),而CountDownLatch又是其中典型的代表,我们今天就继续来学一下这个同步工具类!

CountDownLatch有何作用?

我们知道AQS是专属于构造锁和同步器的一个抽象工具类,基于它Java构造出了大量的常用同步工具,如ReentrantLock、Semaphore、ReentrantReadWriteLock、SynchronousQueue等等,我们今天的主角CountDownLatch同样如此。

CountDownLatch(倒时器)允许N个线程阻塞在同一个地方,直至所有线程的任务都执行完毕。CountDownLatch 有一个计数器,可以通过countDown()方法对计数器的数目进行减一操作,也可以通过await()方法来阻塞当前线程,直到计数器的值为 0。

CountDownLatch的底层原理

想要迅速了解一个Java类的内部构造,或者使用原理,最快速直接的办法就是看它的源码,这是很多初学者比较抵触的,会觉得很多封装起来的源码都晦涩难懂,诚然很多类内部实现是复杂,但我们作为Java工程师也不能只追求CRUD呀,培养自己看源码的习惯,硬着头皮看段时间,代码能力绝对会提升的!

废话说的有点多了,我们直接进入CountDownLatch内部去看看它的底层原理吧

【源码解析1】

//几乎所有基于AQS构造的同步类,内部都需要一个静态内部类去继承AQS
private static final class Sync extends AbstractQueuedSynchronizer {
     private static final long serialVersionUID = 4982264981922014374L;
     Sync(int count) {
         setState(count);
     }
     int getCount() {
         return getState();
     }
 }
private final Sync sync;
//构造方法中初始化count值
public CountDownLatch(int count) {
    if (count < 0) throw new IllegalArgumentException("count < 0");
    this.sync = new Sync(count);
}

几乎所有基于AQS构造的同步类,内部都需要一个静态内部类去继承AQS,并实现其提供的钩子方法,通过封装AQS中的state为count来确定多个线程的计时器。

countDown()方法

【源码解析2】

//核心方法,内部封装了共享模式下的线程释放
 public void countDown() {
 	//内部类Sync,继承了AQS
     sync.releaseShared(1);
 }
 //AQS内部的实现
 public final boolean releaseShared(int arg) {
  if (tryReleaseShared(arg)) {
  		//唤醒后继节点
         doReleaseShared();
         return true;
     }
     return false;
 }   

在CountDownLatch中通过countDown来减少倒计时数,这是最重要的一个方法,我们继续跟进源码看到它通过releaseShared()方法去释放锁,这个方法是AQS内部的默认实现方法,而在这个方法中再一次的调用了tryReleaseShared(arg),这是一个AQS的钩子方法,方法内部仅有默认的异常处理,真正的实现由CountDownLatch内部类Sync完成

image

【源码解析3】

// 对 state 进行递减,直到 state 变成 0;
// 只有 count 递减到 0 时,countDown 才会返回 true
protected boolean tryReleaseShared(int releases) {
    // 自选检查 state 是否为 0
    for (;;) {
        int c = getState();
        // 如果 state 已经是 0 了,直接返回 false
        if (c == 0)
            return false;
        // 对 state 进行递减
        int nextc = c-1;
        // CAS 操作更新 state 的值
        if (compareAndSetState(c, nextc))
            return nextc == 0;
    }
}

await()方法

除了countDown()方法外,在CountDownLatch中还有一个重要方法就是
await
,在多线程环境下,线程的执行顺序并不一致,因此,对于一个倒时器也说,先开始的线程应该阻塞等待直至最后一个线程执行完成,而实现这一效果的就是await()方法!

【源码解析4】

// 等待(也可以叫做加锁)
public void await() throws InterruptedException {
    sync.acquireSharedInterruptibly(1);
}
// 带有超时时间的等待
public boolean await(long timeout, TimeUnit unit)
    throws InterruptedException {
    return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));
}

其中await()方法可以配置带有时间参数的,表示最大阻塞时间,当调用 await() 的时候,如果 state 不为 0,那就证明任务还没有执行完毕,await() 就会一直阻塞,也就是说 await() 之后的语句不会被执行。然后,CountDownLatch 会自旋 CAS 判断 state是否等于0,若是就会释放所有等待的线程,await() 方法之后的语句得到执行。

CountDownLatch的使用

由于await的实现步骤和countDown类似,我们就不贴源码了,大家自己跟进去也很容易看明白,我们现在直接来一个小demo感受一下如何使用CountDownLatch做一个倒时器

【代码样例1】

public class Test {
    public static void main(String[] args) throws InterruptedException {
        // 创建一个倒计数为 3 的 CountDownLatch
        CountDownLatch latch = new CountDownLatch(3);

        Thread service1 = new Thread(new Service("3", 1000, latch));
        Thread service2 = new Thread(new Service("2", 2000, latch));
        Thread service3 = new Thread(new Service("1", 3000, latch));

        service1.start();
        service2.start();
        service3.start();

        // 等待所有服务初始化完成
        latch.await();
        System.out.println("发射");
    }

    static class Service implements Runnable {
        private final String name;
        private final int timeToStart;
        private final CountDownLatch latch;

        public Service(String name, int timeToStart, CountDownLatch latch) {
            this.name = name;
            this.timeToStart = timeToStart;
            this.latch = latch;
        }

        @Override
        public void run() {
            try {
                Thread.sleep(timeToStart);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(name);
            // 减少倒计数
            latch.countDown();
        }
    }
}

输出:

3
2
1
发射

执行结果体现出了倒计时的效果每隔1秒进行3,2,1的倒数;其实除了倒计时器外CountDownLatch还有另外一个使用场景:
实现多个线程开始执行任务的最大并行性

多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开跑。

具体做法是:
初始化一个共享的 CountDownLatch 对象,将其计数器初始化为 1 (new CountDownLatch(1)),多个线程在开始执行任务前首先 coundownlatch.await(),当主线程调用 countDown() 时,计数器变为 0,多个线程同时被唤醒。

【代码样例2】

public class Test {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(1);
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                try {
                    System.out.println("5位运动员就位!");
                    //等待发令枪响
                    countDownLatch.await();

                    System.out.println(Thread.currentThread().getName() + "起跑!");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        }
        // 裁判准备发令
        Thread.sleep(2000);
        //发令枪响
        countDownLatch.countDown();
    }
}

输出:

5位运动员就位!
5位运动员就位!
5位运动员就位!
5位运动员就位!
5位运动员就位!
Thread-0起跑!
Thread-3起跑!
Thread-4起跑!
Thread-1起跑!
Thread-2起跑!

结尾彩蛋

如果本篇博客对您有一定的帮助,大家记得
留言+点赞+收藏
呀。原创不易,转载请联系Build哥!

image

如果您想与Build哥的关系更近一步,还可以关注“JavaBuild888”,在这里除了看到《Java成长计划》系列博文,还有提升工作效率的小笔记、读书心得、大厂面经、人生感悟等等,欢迎您的加入!

image

工作无需燃烧自己

在前面的文章中,笔者已经介绍了工作中没有努力一说,而是要做到四步走:了解情况,做出决策,抓住核心,运用手段。我相信同学们仍然会觉得笔者的说法过于绝对。那我凭借着这四步走,做很多很多业绩我不就能往上爬了,不也是努力工作嘛。我还是在工作中努力不是嘛,笔者想说的是,就算是从这个角度入手,工作仍然没有努力一说。

笔者一直反复强调的一点就是,工作不是学校,不是努力学习就一定会有好成绩的,何况就算是这样,因为题目没出在自己擅长的领域发挥失常也是常有的事情,凡事最佳状态都只有相对公平,没有绝对公平。那就更明显了,我们都知道有人的地方就会有江湖,人心隔肚皮,很少能够有完全了解别人心思的可能性。事实上,我们大可不必避讳,虽然赌博是违法的,但是有人的地方,成功与否就一定会牵扯到赌的性质,我们固然可以通过了解赌局运筹帷幄,增加筹码,但是成功与否是没有人可以百分百把握的。在法学界就有这么一句有意思的话:判决轻重有可能取决于法官的早餐,尽管法官不允许情绪化工作,但是多少会受到情绪的影响,如果有些法官对被告感同身受就容易轻判,反之对原告感同身受就容易重判。那么在法庭这么一个严肃的地方都会出现这种现象,工作的不确定性就更明显了,如果同学们指望通过努力工作能够升职加薪的话,这种行为其实和赌博是一样的,如果同学们拿命去工作指望升职加薪的话,那就相当于是买定离手。赌局中的买定离手,并不是说一定会输,也有一发入魂走向暴富的,如果是这群人一定会告诉你努力工作是对的,但是古话说十赌九输,买定离手如果输了那最后就真的会一无所有,妻离子散。

相信不用笔者描述,很多同学也都知道了,往往裁员都是裁在公司里最辛苦的那个。我们继续拿赌作为类比,大家知道,如果在赌局中想增加赢的概率,那么要做的一件事是什么,筹码要多对吧。庄家和赌徒为什么是不对等关系,因为庄家的筹码是用不完的,而赌徒的筹码是有限的,赌徒能和庄家打成平手甚至偶尔赢一下那是最好的结局,千万别指望能掀桌子。而我们也是一样的道理,同学们在刚刚加入职场的时候是什么情况,其实都不用分析就知道了,属于是一问三不知,光有一膀子力气能干活。因此,我们年轻的时候,精力旺盛是我们唯一的筹码。但是这个筹码并不是永久的,随着年龄的增长,我们的精力会逐渐下降,就好像郭德纲经常自我调侃,20岁站在台上不知道累,40多岁站在台上说两段就腰酸腿疼。但是为什么很多人到了一定岁数能站稳脚跟,因为他靠着年轻的时候不断探索总结,用精力交换阅历和技能,作为新的筹码,言下之意他的筹码不仅没有少,反而更多了。打个不恰当的比喻,这就叫飞行员比飞机值钱,飞机生产线开足马力可以源源不断生产,但是培养一个飞行员那都是能拿吨位黄金衡量的,精力可以造出飞机,但是飞不起飞机,其实精力是很不值钱的。

所以同学们要注意自己的情况,刚刚加入职场的时候,如果不是富二代,我们现在就相当于赌徒,职场就是庄家,精力尽管不值钱,但是是唯一的筹码,在这张赌局博弈中,一开始要做的绝对不是直接下注,而是要博弈,用自己的筹码和庄家做交换,等到换到更好的筹码再下注,这样不能保证一定赢,但是至少可以增加赢得可能性,但是如果同学们一味地燃烧自己工作,相当于把自己仅有的一个筹码扔了出去,直接宣布买定离手,那笔者只能祝你好运了。

工作讲究各司其职

玩英雄联盟,王者荣耀,平安京的都知道,这是个团队游戏,而且玩游戏玩的多的同学都知道,哪怕自己是万金油补位,也一定有一个自己最适合玩的位置,如果像打一些比较重要类似战队赛,晋级赛这样的局,一般都不太愿意让出自己最擅长的位置。所以,每个人都有自己最适合的位置。在职业选手的话就更加明显了,关注英雄联盟赛事的同学都知道,大部分选手都有属于自己独有的位置,像S11 xiaohu这样的中单转上单那也是偶然现象,很快S12 xiaohu就回到了中单的位置。

事实上每支队伍都代表着俱乐部的成绩,就相当于是公司的绩效,不管是什么成绩,这都代表了他们拿出的阵容情况下,所能达到的极限。任何一支夺冠的队伍,都代表着是最正确的五个位置上的五个人

因此,大多数工作都是以团队的形式完成的,绝对不是靠一个人完成的,就好像Faker再厉害也不可能出现五个位置都是Faker的情况,而且对五个人一定都在自己最适合的位置,就拿S11的冠军阵容来说,如果换成上单:meiko,打野:scout,中单:viper,下路:jiejie,辅助:flandre,我估计别说夺冠了,世界赛都进不去。

那么在工作中也是一样的道理,后端开发,前端开发,数据库管理员等等,这些都有自己的岗位,大家需要共同努力才能把项目做好,如果一个人包办了一个项目中的上上下下,这绝对不是一个合理的现象,
笔者还是希望像之前的文章中提到的,要找到最适合自己的位置

那么其他岗位的东西要不要学呢,笔者还是那句话,必须要学,例如meiko虽然是打辅助的,但是在练习的时候也是玩过其他位置的,这不是为了抢饭碗,而是为了站在别人的视角看问题,毕竟你只有站在别人的视角看才知道怎么去配合别人。

就好像作为后端一定会给前端接口,后端postman一调通就认为没问题,直接甩给前端,但是有时候前端一看就苦不堪言了,因为后端封装的数据往往不符合前端的业务需求,这就经常出现了前端和后端的互相拉扯,谁都不肯去处理问题。

但是在工作中要不要干呢,请注意笔者接下来的话:可以偶尔帮忙,但是千万别长干,同学们一定不要偏离自己最擅长的位置,从而本末倒置。相信有很多同学,一直在努力工作,甚至为了绩效可以说从开发,实施,测试什么都管了,可是干的越多,犯的错误就越多,挨骂嫌弃就越多,结果越做越没信心,如此形成恶性循环。到最后可能就剩得到一句毒鸡汤:年轻人多干点,多吃点亏不是坏事。事实上,会造成这样的原因恰恰就是你什么都做,但是笔者前面的文章就提到了,人的发展是有局限性的,你这不仅耗费精力时间,还容易多做多错。注意,多做多错绝对是个客观的概率问题,你固然不能拿这个当摸鱼的借口,但是也绝对别去接不属于自己的活。那你犯得错误越多,你当然就会走向越做越没信心,越做越差。

工作打的是持久战

同学们从加入职场到退休前后有45到50年,虽然说说很简单,但是同学们想必最关心的就是自己到底擅长什么,因此,我们一定会花很多时间来探索。这个确实是没有办法的事情。事实上,这个探索,就是笔者一开始提到的,精力交换筹码,同学们的精力旺盛是留在这个地方的。这里笔者也没法给大家决定哪个方向合适,说白了前景好的未必适合你,前景不好的未必在你身上灵验,笔者的建议还是大家要多思考多做职业规划,可能很多同学此时会把那句话拿出来,一次正确的选择大于千千万万努力,是的你说的没错,但是几乎不可能一次性做出正确的选择,而且有时候会像哪吒:魔童降世那部电影一样,设计了300多款哪吒,结果启用的是第一版,同学们可能第一次就选择了最正确的岗位,但是却不自知,也是很有可能的现象。所以,希望大家把精力留在这里,而不要过多的去拿命工作,你一味的燃烧自己工作,指望着靠业绩换升职加薪是很不靠谱的决策。

而且按照马克思的真理和谬误的观点,你所做出的选择可能是这段时间内是正确的,过段时间可能就变了,就好像郭德纲那个相声段子,于老师大学学的最热门的BB机修理专业,结果人没毕业,BB机毕业了,当然这是玩笑话,但是同学们可能干几年手里的技术不流行了也是很正常的事情,所以绝对不要过多地去挥霍自己的精力。

但是,笔者希望大家能分清楚心态,工作不要投入过多的精力,学习要往死里学,这个时候笔者希望大家能够明白,学习是百分百是有回报的事情,是一个无本万利的投资。而且笔者在前面的文章中就提过了,会的东西越多,你能做出的决策主动权也就越大。就好像网上经常说的,未来多少多少年是Python,人工智能的时代,有的年入300w+了,笔者不知道这些是不是真的,如果是真的,到时候你不会,那你就算能做出决定,你干得了嘛,如果不是真的,多学点东西也不亏不是嘛。

长夜里请保留火把

很多同学年轻的时候都对油腻腻的中年人嗤之以鼻,其实大部分中年人年轻的时候都和现在的同学们一样热血,但是他们就犯了笔者前面提到的禁忌,但是直接说努力是不对的,那谁信呢,那他们只能把这一切都归结于社会的毒打,所以可以看得出,他们的负能量就特别重。

很多同学对电视剧《潜伏》肯定不陌生,我们抛开政治立场,如果把天津站比作一个公司的话,那么天津站的损失就是绩效不佳,对于站长来说他就是公司的老板,他曾经也热血的想把天津站建成一个大站,不然不会秋掌柜企图咬舌自尽的时候站起来表示敬意,而且从剧中来看他也做过努力,袁佩林事件就是个很好的例子,甚至还出现了金句:本来想露脸,结果把屁股漏出来了,但是很遗憾,接二连三的受挫让天津站始终看不见曙光,后来在钱斌事件以后,站长直接对余则成说了这么一番话:“则成啊,天津站的得失在什么,在几个偷偷摸摸的军官吗?笑话!那么多重兵把守的大城市丢了,那么多战功卓著的整编军丢了,什么原因?我们还在这儿搜集情报抓内奸查帮派,试图保住大天津堡垒,不滑稽吗?我年轻的时候也好斗,也清高,可你看看我现在剩了什么,除了衰老和靠贪污得来的那些东西,我想犯错误,我想被革职”。

此时连余则成都不解,为什么站长会对一个怀疑身份的人说出这样一番话来,因为站长此时已经达到了相当的年龄,不可能再像过去一样不知疲劳地拼命工作,而无尽的挫折让站长迟迟看不到希望,只能无能为力地堕落下去。站长最后甚至对余则成说出了:和翠萍找一个安静的地方,过安定的日子。

同学们自己看看,站长对余则成说出的这番话像不像一个油腻腻的中年人对你说出的:【“努力工作没啥用,做保安少走二十年弯路”】【“赶紧找个好人家嫁了,少奋斗20年”】,这就是典型的把原因归咎于社会的毒打,如果站长能够保留一些心力的话,也许能够像余则成一样改变立场,弃暗投明。

而且这世上从来没有常胜将军,三十年河东三十年河西是必然的事情,有巅峰就会有低谷,S14 EDG开局七连败,最后解说心酸的说出了这么一段话:EdwardGaming 这支无数次踏足山巅的顶尖战队,现在迎接他们的却似乎是看不见曙光的漫漫长夜,现在马上就要迎来他们的第七场败局,而他们好像无能为力….火把会在谁手里呢,可能是jiejie,可能是阿乐,可能是年轻的小将们,包括Faker所在的T1,从S7的鸟巢沉沦到S13的登神长阶,他在拿到第四冠前也经历了长达七年的寒冬,对于一个职业选手来说这段岁月实在是太久了。任何人事物,都有兴衰周期律,谁都逃不过。熬过去了就是新生,熬不过去就是迷失。

希望同学们记住,无论曾经多么辉煌,一定会有一个黑夜等待着你,或早或晚,因此,笔者先前告诉大家不要过分的挥霍精力,一方面是精力是为了交换筹码,另一方面是为了在长夜中能作为火把照亮前方,等待曙光的出现。如果你在年轻的时候过分地挥霍精力,那你很有可能会迷失在黑暗中,最后成为你曾经最讨厌的人

结论

工作大概按照阶段可以给出这么几个点

【1】没有经验,精力充沛的时候,请不要过分挥霍,因为你此时就这么一张王牌

【2】每个人都有最适合自己的位置,你可以了解其他岗位所需的一切,也必须了解,但千万别去干

【3】没有人能在两三年时间里确定自己最适合什么,留足够的时间给自己探索

【4】任何人都逃不过兴衰周期律,在长夜中你先前没有挥霍的精力将成为你黑夜里的火把

分享是最有效的学习方式。

博客:
https://blog.ktdaddy.com/

故事

这是一个真实事件,三年前老猫负责公司的支付资产业务。为了响应上级号召,加强国央企之间的合作,公司新谈了一个支付对接的渠道(当然这个支付渠道其实很冷门的,也是为了对接而对接,具体哪个渠道也不方便透露),由于原始支付系统的第三方支付可拓展性设计得还不错的,所以老猫对接的也是比较快的,熟悉对方的对接文档之后对着编码就好了,差不多花了三天的时间就完成联调了。一切看似很顺利地上线了。

时隔几天,收到了一个快递包裹,是一袋价值53块钱的“原皮腰果”,当时诧异,翻看了各大消费平台,都没有之前的下单记录,后来和媳妇确认了一下,她也没有下单。“难道是某个崇拜哥的小姑娘送的?不能吧”当时心里美滋滋地yy着。

不过之后的一个客诉问题,引起了老猫的重视,老猫排查下来发现一个很重大的问题,钱款的扣除和实际的订单状态对不上。说白了就是订单完结了,但是账户资产并没有完成扣除。我瞬间明白了之前那个“原皮腰果”是怎么回事儿了,当时在生产测试渠道的时候,在公司内部商城提交了订单,但是并没有付款,然而订单却成功了。

再三确认之后,确实存在这一问题。一瞬间整个人心态崩了,头皮发麻,口干舌燥,心脏“突突突”。怎么办?怎么办?生产还不知道涉及多少单子,没办法,兜不住了,先把这件事情往上抛吧(向上级领导汇报)。

具体原因是什么呢?我们来看一下对接第三方支付的大概时序流程。

时序

我们一般在对接第三方支付渠道的时候会有上面一些基本流程。

1、当我们内部生成待支付单之后会请求外部第三方支付渠道,此时第三方支付渠道内部会生成待支付单。

2、我们第三方支付单创建成功返回之后,一般内部系统会唤起收银台,然后用户确认支付。

3、第三方支付成功返回消息之后,整个支付就算已经完结了。

但是问题就出在了第三方支付渠道还有一个定时异步通知的任务,并且我们也对接了这个接口。这个异步通知的功能主要是会定时告知我们支付系统第三方支付单的状态。而且无论成功与否,都会轮询告知,例如,如果是待支付,对方会告知状态是0,如果已完成支付,对方会告知状态是1。我们拿到支付结果之后就会执行后续的订单完成流程。

收到异步通知之后的代码处理判断如下:

事故代码

    //校验交易状态
    if (!Objects.equals(notifyModel.getTradeStatus(), TradeStatus.SUCCESS.getCode())
            && amtConvertY(notifyModel.getTradeAmt()).compareTo(thirdPartyCharge.getAmount()) != 0) {
        LOGGER.error("交易状态不正确:{}", notifyModel.getOutTradeNo());
        throw new BusinessRuntimeException(Errors.PAY_NOTIFY_IS_FAIL);
    }

正确代码

    //校验交易状态
    if (!Objects.equals(notifyModel.getTradeStatus(), TradeStatus.SUCCESS.getCode())
            || amtConvertY(notifyModel.getTradeAmt()).compareTo(thirdPartyCharge.getAmount()) != 0) {
        LOGGER.error("交易状态不正确:{}", notifyModel.getOutTradeNo());
        throw new BusinessRuntimeException(Errors.PAY_NOTIFY_IS_FAIL);
    }

相信眼尖的小伙伴已经发现了,其实就是“||”和“&”的区别。第一种情况当对方告知状态为0的时候,其实并不会被拦截掉,而是直接走了往后的流程,于是悲剧就发生了。

直接说一下最终的处理,最终其实还是比较幸运的。由于,我们本身已经对接了微信以及支付宝的支付渠道,再加上这个渠道的支付使用的频率还是非常少的,很多用户不太会使用这个渠道进行支付,所以最终盘算下来整个的资损金额差不多是3w左右,另外的是其中有个不幸中的万幸。是因为老猫在这之前做了一套资金追讨系统,该系统可以定位出那些用户“空手套白狼”了。并且能给这些用户生成对应的待支付订单,用户可以通过这些待支付订单最终完成资金的补偿付款,最终完成了资金了追讨。所以事后,完全盘点之后发现一共的资损是1600左右。

最终,也算是有惊无险。但是这次的经历给了老猫上了一课。

下面总结一下我们在做支付账务系统的过程中应该如何进行资金安全相关的设计,最终做到防患于未然。

概览

资金安全设计

针对资金安全的问题,不限于通过技术手段避免资损,其实很多时候我们还需要结合数据核对、监控等措施,做到快速发现资损并且止损。下面我们来一一盘点一下资金安全设计的一些点,希望能给大家一些帮助。

资损风险分析

风险要素

在我们做支付资产系统的时候,我们其实需要好好盘点一下资损风险,这些风险可能来自于各个方面。下面涉及,

资金流:我们实际的产品业务中,尤其是支付资产的时候,其实往往会有很多类型的资产形态,可能是积分,可能是现金,当然还有可能是优惠券等等。看到这些金额的时候,我们需要确保上下游系统的一致性、金额计算的正确性、逆向金额不能大于正向金额等等。

交互:我们需要考虑客户端展示内容是否正确。尤其是小数点展示的精确位上,很容易出现客户端展现信息不全的问题,实际其实为99.99元,但是展示的时候却为99.9或者9.99。

资金规则:资金规则是为资产本身的产品形态规则,举个例子,发放优惠券这个行为,每个人发多少,发的时间点,发放的人数,发放的门槛等等。再比如某个积分资产的限额,其中又涵盖着日限额以及月限额。

异常:存在资损风险很多时候其实由于异常导致的,抛开系统本身异常之外,我们还要考虑网络抖动异常,其他服务异常。如果系统本身的事务处理不好,或者最终一致性没有做好,就很有可能造成资损。

技术风险

系统发生资损么,很大一部分就是系统没有设计好或者是编码过程中的粗心,例如上面老猫的真实案例。那么且抛开粗心这个人为因素,我们盘点一下本身技术风险,这些技术风险场景主要来源于多并发、幂等、分布式事务、上下游服务超时、数据计算精度、接口协议、校验逻辑的不严谨等等。

上面罗列的这些很多都为一致性的问题。我们一个个来看。

1、并发:多线程、同时对数据进行读写处理的时候,就有可能造成一致性的问题,例如用户资产重复支付,积分超发等等,如果在系统层面还用了缓存的话,还有可能存在缓存未刷新,导致数据库和缓存不一致的情况。

2、幂等:在支付资产系统中,接口幂等性是十分必要的,接口的幂等能够很大程度上避免(1)中提到的资产重复支付问题、下单重复问题以及网络重发问题。关于幂等详细设计,其实老猫在以前的文章中也有梳理,大家有兴趣的可以看这里【
前任开发在代码里下毒,支付下单居然没加幂等

3、服务超时:系统所依赖的服务执行结果返回慢,造成上下游数据状态不一致,例如核心的支付服务调用底层的资产服务进行扣款,结果由于资产扣款逻辑返回超时,导致两边数据不一致。

4、接口规范:尤其是对接第三方接口的时候,文档上的必填字段和非必填字段如果本身文档就有出入,可能就是致命的。

5、事务:其中包含本地事务以及分布式事务,研发在开发过程中对事务理解不够透彻,使用不严谨,最终导致数据不一致。

6、数据精度:主要在金额四舍五入的场景,最终导致精度丢失。或者上下游系统精度不一致。

防止资损

如果说想要彻底的避免资损,并不是一件容易的事情,系统链路复杂,任何一个环节出现问题都有可能导致最终的资损。我们虽然是技术,但是我们的眼光其实不能够仅仅局限在技术的眼光去看待资损这个事情,除了技术侧尽量规避资损发生之外,其实还有其他方案,例如下图。

步骤

上图准确来说其实是一个防止资损,或者说尽量保证企业损失最小的一系列的手段以及方案。这些步骤,其实从时间上来看是不同的时间线。以下咱们来一一看一下。

1、技术规避:

技术侧我们当然要保证我们自身代码的严谨性。这里主要提及的还是上述所说的一致性的问题。我们在系统开发的过程中要挖掘系统可能出现问题的点,其中可能包含事务的使用、接口需要做好幂等设计,系统和系统交互过程中需要考虑接口的重试机制等等。当然这些都是咱们研发在实际开发的过程中需要注意的点。

2、对账发现:

很多时候,资产支付系统上线出问题,并不是直接的日志异常。这种异常可能还好,容易被发现,因为已经卡流程了。怕就是怕在不知不觉的情况下,看似风平浪静,其实内部数据已经一团乱麻。资损已经产生了,就像老猫上面遇到的这种情况。这种情况的发生其实主要还是由于没有做好相关的对账措施。从而导致了悲剧的发生。其实如果我们能够做到每日对账,可能问题就能及时被发现。

对账方式:我们可以从上层系统一路往下游系统进行对账。例如,我们有这样几个系统,例如,上层电商业务系统,订单系统,支付核心系统,第三方支付系统。那么我们对账的方式就如下。

对账

咱进行对账的过程中,我们一般是系统之间进行两两单据对账,对账维护有两个方面,第一个是总数量,第一个是总金额。如果我们发现所有的系统能够两两账目对齐,那么系统就没有太大问题。

清洗对账

当然很多时候单据数量可能由于业务的原因是不对等的,所以这个时候可能还需要进行一定的数据清洗,然后才能进行对账处理。做得好点的话,可以将对不齐的单子能够自动告警,告警方式可以是短信或者邮件方式,当然也可以支持相关人员能够看到每日的对账看板。

这种对账的方式可以协助我们及时发现系统上的问题。老猫之前对接的那个渠道,其实还没有来得及做对账。因为那时候我也疏忽了,认为当前第三方渠道比较冷门,所以就偷个懒没有做对账。然而最终还是逃不过“墨菲定律”。所以咱们研发在做系统上的事儿的时候还是不要抱有任何的侥幸心理。

3、应急止损:

如果真的到了这一步,其实悲剧已经发生了,这个时候其实是比较考验心态的。因为系统漏洞已经造成了资损,并且资损还在持续。这时候内心就会燥热,像老猫那样口干舌燥,着急得像热锅上的蚂蚁。这个时候如果越想早点修复问题,可能越容易出错。很多时候人在着急得时候往往会病急乱投医。为了快速解决问题,修一下bug就直接上线。这种很容易导致错上加错。

所以当我们意识到问题已经发生了,就要向上汇报了,让上级知道这个事情,然后一起看一下问题的处理。这样的话多个人一起把控修复问题肯定比当事人一个人默默修复问题来得好。所谓“当局者迷旁观者清”是有道理的,这样也至少可以降低二次错误的概率。所以出现问题后,一定不能慌了手脚。唯一要做的就是冷静,然后一步步梳理处理的步骤。

当然如果有条件的话,可以根据当前的业务模式开发一个资金追讨系统来防范未然,当然这个系统真的希望是一辈子都用不上,然而这个系统可能是最后的一道屏障了。

总结

以上就是老猫的一段经历,大家可以当做乐子看个热闹。如果真的对你有所帮助,也希望能够得到你的点赞和收藏。当然,如果你也恰好维护同样的系统,对于这样的系统维护有其他新的认知,也欢迎大家能够在评论区留言。

在开发大型的项目时,往往会有很多人参与协同开发,划分成各个小组负责不同的模块,模块之间相对独立。代码中会定义很多的类名、函数名、模板名,甚至一些全局变量,如果不对这些名称加以规范,很容易造成名字的冲突,因为默认情况下这些名字都是全局名字,这种情况也称之为命名空间污染。为了避免这个问题,C++标准引入了命名空间的概念,将不同模块的名字限定在各自模块的命名空间中,命名空间中的名字的作用域只在命名空间内有效,尽可能地避免名字的冲突。命名空间在C++98标准中已经引入,它的概念以及用法这里就不再赘述,现在来介绍的是现代C++标准新增的功能:内联命名空间(C++11)和嵌套命名空间(C++17),以及在C++20中的改进。

内联命名空间

C++11标准引入了内联命名空间的概念,它的语法就是在namespace前面加个inline关键字,如:

inline namespace MyCode {
    // source code
}

内联命名空间中的名字可以被上层命名空间直接使用,也就是说,我们无需在内联空间的名字前添加该命名空间的名字为前缀,通过上层命名空间的名字就可以直接访问他,如下:

namespace MyCode {
    namespace Lib_V1 {
        void foo() {}
    }
    inline namespace Lib_V2 {
        void foo() {}
    }
}

int main() {
	MyCode::Lib_V1::foo();
    MyCode::foo();
}

调用Lib_V1命名空间的foo函数,前面需要加上Lib_V1的前缀,而访问Lib_V2命名空间的foo函数则不需要。内联命名空间的作用之一是,当我们有一个模块,这个模块提供了一组接口供外部调用,有时我们需要升级接口以提供不同的功能,而新接口不与老接口兼容,我们希望新写的代码将调用我们提供的新接口,但是又不希望影响老的代码,所以老的接口需要保留。这时就可以使用内联命名空间的办法来解决,就如上面的例子中,我们把新接口放在命名空间Lib_V2中,并定义为内联的命名空间,使用者只需通过MyCode前缀就可以访问到它们,如:MyCode::foo(),老的代码的逻辑不需要改动,只需将原来调用接口的地方加个前缀,如MyCode::Lib_V1::foo()。

内联命名空间在第一次定义时必须加上inline关键字,之后再重新打开命名空间时可以加上inline关键字,也可以不加上。

嵌套命名空间

嵌套命名空间在C++98中已有,如上节中的代码就定义了一个嵌套命名空间,但它的写法比较冗余,如果要定义多重的嵌套则显得更加冗余,特别是在代码缩进时,比如:

namespace A {
    namespace B {
        namespace C {
            void foo() {}
        }
    }
}

访问foo函数时通过A::B::C::foo()来调用,如果定义命名空间时也可以像这样的话代码将会变得更加简洁,因此C++17标准中引入了更简洁的嵌套命名空间的定义方式,如:

namespace A::B::C {
    void foo() {}
}

这样代码就显得简洁得多,它也更符合我们的使用习惯。当遗憾的是,在C++17中没有解决在嵌套命名空间中定义内联命名空间,也就是说在上面的嵌套命名空间中没法加入inline关键字,使得子命名空间成为内联的,直到C++20标准中完善了这个功能。因此在C++20中,我们可以通过以下的方式来定义命名空间:

namespace A::B::inline C {
    void foo() {}
}
// 它等同于如下定义:
namespace A::B {
    inline namespace C {
    	void foo() {}
    }
}
// 调用foo函数:
A::B::foo();

// 或者也可以这样定义:
namespace A::inline B::C {
    void foo() {}
}
// 它等同于如下定义:
namespace A {
    inline namespace B {
        namespace C {
            void foo() {}
        }
    }
}
// 调用foo函数:
A::C::foo();

需要注意的是,inline关键字可以出现在除第一个namespace之外的任意namespace之前,上面的代码需要使用支持C++20标准的编译器来编译,在编译时加上参数-std=c++20。

此篇文章同步发布于我的微信公众号:
内联和嵌套命名空间

如果您感兴趣这方面的内容,请在微信上搜索公众号iShare爱分享或者微信号iTechShare并关注,或者扫描以下二维码关注,以便在内容更新时直接向您推送。
image