V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
V2XEX
V2EX  ›  Java

CRUD 工程师提问:最佳实践是把逻辑放在数据库中还是后端代码中 ?

  •  
  •   V2XEX · 2018-12-08 19:57:52 +08:00 · 6875 次点击
    这是一个创建于 2175 天前的主题,其中的信息可能已经有所发展或是发生改变。

    crud 搞久了,最近坐下来想些问题就发现脑子有点乱。 假如现在有这么一个按类别查询某用户未读帖子数量场景:

    有 2 张表
    用户表 user,字段: 用户唯一标识符:uuid
    帖子表 post,字段: 帖子类别:type ; 帖子已读用户:readers (用户每打开一个帖子就往这个字段写入用户 uuid,并以逗号分隔)

    Java 代码中有对应的实体类,orm 使用 Spring Data Jpa。

    现在需要按类别查询某用户未读帖子数量,有两个方案:
    1、直接查出所有帖子的类型和已读用户字段,然后用 Java8 的 Stream.filter、Collectors.groupingBy 来过滤、分类,直接给前端一个返回一个 Map (体现了 orm 的思想……)
    2、用包含 couting、not like、group by 等关键字的 sql 直接查出结果,直接给前端返回一个 Set<map>。

    如果使用方案 1,那么项目这部分 Java 代码应该放在哪里( service or controller )?项目结构应该怎么划分呢?

    虽然问题很小,不知道这算是钻牛角尖不……请有经验的 Ver 指教下

    42 条回复    2018-12-23 12:52:41 +08:00
    raphael008
        1
    raphael008  
       2018-12-08 20:01:14 +08:00
    方案 1 放 manager 层里
    xy90321
        2
    xy90321  
       2018-12-08 20:04:23 +08:00 via iPhone
    除非你用的数据库性能很差或者不提供类似功能语法的支持,否则我看不出全拿出来有什么好处。特别是你的数据量稍微大一点的时候,那已经不是蛋疼而是蛋碎了。(前提是如果内存和磁盘还没爆炸的话)

    更重要的是,很多复合 SQL 语法你要自己在 Java 端实现一遍那简直就是…
    tomczhen
        3
    tomczhen  
       2018-12-08 20:08:40 +08:00
    给钱少、工期短,方案 1 就是最佳实践。

    给钱多、工期足,方案 2 就是最佳实践,顺便弄点高大上的技术词汇,什么分布式、中间件都给整上。
    Inside
        4
    Inside  
       2018-12-08 20:09:30 +08:00
    帖子已读用户放到数组里,execute me ?对关系的理解和认识本身就有问题。
    这种认识直接导致了方案一这种瞎搞的方案。
    tomczhen
        5
    tomczhen  
       2018-12-08 20:09:41 +08:00
    @tomczhen 说反了......
    V2XEX
        6
    V2XEX  
    OP
       2018-12-08 20:17:59 +08:00
    @xy90321 其实就算不全拿出来,统计的根本逻辑也是在数据库中实现了,两个做法的区别就是统计的逻辑放在哪里
    V2XEX
        7
    V2XEX  
    OP
       2018-12-08 20:18:45 +08:00
    @Inside 那就这个功能来说,数据库应该如何设计呢?请指教……
    liuhuansir
        8
    liuhuansir  
       2018-12-08 20:32:52 +08:00
    应该再加一张已读帖子与用户多对多的表,用帖子总数减去已读数得到结果,一个业余后端的建议
    MegrezZhu
        9
    MegrezZhu  
       2018-12-08 20:33:24 +08:00
    首先既然是 SQL 数据库的话,这样设计表是有问题的…应该抽出一个用户已读帖子的表( userid+postid ),然后做一些外键 /索引,这样直接通过 SQL 查询的时候数据库能对查询做优化,不用读取全部数据。第一种方法在数据量大的时候基本不现实。
    可以去看看数据库范式,挺经典的理论。
    xiangyuecn
        10
    xiangyuecn  
       2018-12-08 20:37:31 +08:00
    最佳实践是根据实际情况合理搭配和选择。。。算了,还是先把那个设计这个表结构的打死了再谈后面的吧,哈哈
    ruandao
        11
    ruandao  
       2018-12-08 20:45:56 +08:00
    这个要考虑 数据库的 IO 成本和计算量
    houyujiangjun
        12
    houyujiangjun  
       2018-12-08 20:51:20 +08:00
    这是一个领域模型驱动的问题.
    V2XEX
        13
    V2XEX  
    OP
       2018-12-08 21:21:41 +08:00
    @MegrezZhu 但是这么设计的话这张中间表的记录数很容易就是几何级数增长啊,而且当有删除需求时这张表将承载更多任务
    V2XEX
        14
    V2XEX  
    OP
       2018-12-08 21:22:50 +08:00
    @xiangyuecn 等我意识到这么设计表有多蠢的时候就会扇自己两巴掌
    MegrezZhu
        15
    MegrezZhu  
       2018-12-08 21:26:28 +08:00
    @V2XEX 已有的帖子表 post 里面本来就存了每个帖子的已读用户,把它抽出来并不会增加多少负担。数据量级是没有变化的。
    V2XEX
        16
    V2XEX  
    OP
       2018-12-08 21:46:35 +08:00
    @MegrezZhu 有 n 个帖子,m 个用户,那么这张中间表至多就会有 m × n 条记录,以后每新发一个帖子至多会增加 m 条记录。还需要考虑删除情况……这样的开销对于原来直接将用户 uuid 写入帖子表某个字段来说不知道哪个更优?
    chanchan
        17
    chanchan  
       2018-12-08 21:55:26 +08:00
    我的习惯是 2
    azzwacb9001
        18
    azzwacb9001  
       2018-12-08 22:25:34 +08:00
    好问题。我是一个菜鸟,但我觉得方案 2 是比较合理的方案。如果不考虑具体的场景,那我觉得这个问题可以这么看:
    如果从数据库中取出来的数据,没有在中间层进行二次加工的需求,那就使用方案 2 ;如果一些从数据库中用比较复杂的 SQL 语句取出来的数据,还可能二次加工或者供多方使用,那就用方案 1.

    不知道我有没有理解楼主的问题= =我没搞过 JAVA
    MegrezZhu
        19
    MegrezZhu  
       2018-12-08 22:31:58 +08:00
    @V2XEX 直接将 uuid 写入帖子表的话,帖子表里面不也一样会是至多 m × n 个 uuid 吗,顶多减少了帖子 tid 的存储空间,所以我才说不会有数量级上的差距。
    而且考虑删除情况的话,考虑在某个帖子下删除某个用户的阅读记录(呃,为啥会有这个需求,还是我理解错了?),首先就会有 O(n)的查询复杂度。相对地如果是采用访问记录表的话,依靠索引可以近似地达到 O(1)的复杂度。
    如果是删帖带来的删除所有该帖子下的阅读记录的话,方法 1 可能会略有优势,但访问记录表依然可以利用索引高效删除,而且删除操作相对也不多。
    yfl168648
        20
    yfl168648  
       2018-12-08 22:34:44 +08:00
    搞个表,类别、用户、未读数,首次用脚本生成此表数据,然后改造读帖子的代码,如果首次读,未读数减一。这样如何?
    barryng67
        21
    barryng67  
       2018-12-08 22:38:19 +08:00 via iPhone
    一般弄个冗余字段存数,自己写逻辑维护,这样效率高点,数据量大也不怕。
    lihongjie0209
        22
    lihongjie0209  
       2018-12-08 22:41:32 +08:00
    如果架构设计足够好, 封装度足够高, 那么在你的概念中都不应该出现 sql 这个东西, 都是细节
    TomVista
        23
    TomVista  
       2018-12-08 22:45:50 +08:00 via iPhone
    对比下 io 成本和计算成本,然后选合适的
    Kiske
        24
    Kiske  
       2018-12-08 22:50:25 +08:00   ❤️ 2
    是两个问题: 1. readers 字段该不该这么设计. 2.逻辑代码的存放位置.

    1. readers 这个字段, 逗号隔开虽然违反了数据库设计的第一范式,但现在的需求比较简单,只是简单的查出来.

    好处是: 这样做很省事, 不用格外建表, 以后想查询用户是否已读, 用 FIND_IN_SET()就好了.
    坏处是: 就怕以后再复杂点, 让你用这个字段排序和筛选, 就只能用代码写.

    你们根本想象不到以后有多复杂, 因为没法关联查询, 要先拿着这堆用户 ID 去查出来用户, 查出来发现没法分页, 因为还要跨表按热度排序, 你只能手动分页, 而且不是物理分页, lambda 还没法 debug, 别人一接手, 根本维护不动.我写过, 从那以后每次遇到逗号隔开的字符串都有阴影.这就不是关系型数据库应该出现的东西, 真要存逗号隔开的字符串, 干脆对象全都转 json 算了, 字段都不用建.

    2. 逻辑代码想又少又易读, 有非常非常多的地方要注意, 但是存放位置一定要放在 service 层.
    因为 controller 层没有事务啊, controller 确实可以加 @Transactional, 但这样做还分什么层, 直接在 controller 里写 sql 多省事, 以前公司搞了个新框架, 我去一看, controller 里全是拼接 sql 的, 还没防注入,一堆干了十年,五年的人怎么能架构出这种东西,

    所以项目结构应该划分不是那么简单, 既想方便快捷, 又想易于扩展, 很难同时做到, 就算规定好了, 以后也会有人不按规矩来, 有 code review 也挡不住 for 循环里嵌套 for 循环 insert.
    V2XEX
        25
    V2XEX  
    OP
       2018-12-08 22:53:23 +08:00
    @MegrezZhu
    不是啊……
    1 将已读用户写入帖子表意思是把 uuid 写入帖子表的 readers 字段并用逗号分隔,比如像这样:uuid1,uuid2,uuid3
    某个帖子每被浏览一次就更新对应帖子的这个字段
    2 删除是指帖子可能会被删除,而不是删除浏览记录,如果有中间表那对应帖子的所有浏览记录都得删,不知对比将帖子整个删除这是否是个额外的花销(软删除同理)
    MegrezZhu
        26
    MegrezZhu  
       2018-12-08 23:10:00 +08:00
    @V2XEX
    第一点的话,上面的 @Kiske 讲得很好。
    第二点,采用访问记录表的话的确会有额外开销,但我认为这在大部分场景下是完全可以接受的。而如果删除成为瓶颈的话,软删除的方案挺好的。
    V2XEX
        27
    V2XEX  
    OP
       2018-12-08 23:16:52 +08:00
    @yfl168648 确实是个好思路

    @Kiske
    1、单就现在的简单需求(真的不考虑后续维护)来说,两种做法哪种更优?
    2、个人感觉 find in set 不如 like 啊,因为前者的“分组”操作是一个开销,我已用 uuid 储存(非自增 id ),不会出现误查的情况 。不知 MySQL 的 like 查询是否有短路机制。
    3、不瞒你说,我想在在搞的东西需求简单,还真想过把对象全转 json 存数据库,但考虑到数据库操作 json 肯定要经过解析这一步,每条数据都解析一遍开销略大,罢了。你讲的维护的事情涉及到东西很多,有时候不是程序员的水平不行,迫不得已写垃圾代码谁也没办法(每天都有新需求,每天都要改需求,你懂的)。
    4、关于项目分层,我觉得 mvcs 的分层好像和“面向对象”的思想有些出入,本想在本帖一并讨论,但又感觉两者非同类问题。不日我将另发一帖讨论。
    akira
        28
    akira  
       2018-12-08 23:28:06 +08:00
    用户日活一百左右的话,用这个方案没问题
    no1xsyzy
        29
    no1xsyzy  
       2018-12-09 00:40:06 +08:00
    @V2XEX
    我不太清楚各个数据库实现上有什么区别,但字符串应该是顺序存储在一块内的吧。
    也就是说在删除后肯定会产生不规则形状的洞。这些洞要被有效利用上肯定还是要移动其他数据的。

    #27
    垃圾代码问题,只能说水平问题。
    我之前自己有空瞎写的东西,基本上对标到 8 小时也就是每天有新需求和改需求。
    然后工作得很好,有几个月没管。
    之后突然想要重构,包括扩展接口形状。
    结果发现模块化做得很好,就算零注释零文档,重构也没花多少功夫,尽管已经完全不记得上游 API 和代码思路了。
    然后重构完还没完做新的接口又丢在那没管。
    no1xsyzy
        30
    no1xsyzy  
       2018-12-09 00:44:09 +08:00
    @V2XEX MVCS 对应的思想是 reactive 吧,更接近消息机制,或者说面向数据流。
    我重新发现过轮子圆形好,所以还是挺熟悉的。
    hhhsuan
        31
    hhhsuan  
       2018-12-09 01:44:24 +08:00 via Android
    看了各位大佬的回答懵圈了,未读数不就是总数减去已读数吗?总数很容易获取,已读数每次读新帖加 1 就行了,这不是很简单。
    mornlight
        32
    mornlight  
       2018-12-09 01:55:24 +08:00
    not like 要遍历所有这个 type 的 post 记录,post 越多耗时越长。没救了,重新设计存储方案。
    问题出在 readers 字段,既想一个 string 存储所有已读又想对每个已读的 id 做业务,不科学。
    wenzhoou
        33
    wenzhoou  
       2018-12-09 06:21:46 +08:00 via Android
    歪个楼。只有我觉得用户用 UUID 是不对的吗?你不觉得 UUID 太长了吗。
    MegrezZhu
        34
    MegrezZhu  
       2018-12-09 13:29:33 +08:00
    @hhhsuan
    如果需要考虑删帖的话,就还是要维护用户已读帖子的列表的。
    V2XEX
        35
    V2XEX  
    OP
       2018-12-09 18:56:16 +08:00 via Android
    @no1xsyzy 发现模块化做得很好是什么鬼。我说的改需求是:开始只要你打印一个 hello world,后来要你打印十次,再后来要你根据我输入的次数打印并且还要附带我输入的内容……这种的改需求你能在一开始就预料到了?

    如果一定要说面对频繁更改的需求,并在开始写代码前就能预料到客户想法并写出条理清楚、结构清晰,可维护性高的代码如此简单的话,我想“扫码改需求”这种事情就不会成为程序员们所调侃(单自己做的 toy project 不在我说的范围内,产生需求和解决需求都是自己,没有什么东西在约束和评价,与实际多数人都在从事的开发工作不是一回事)
    fox0001
        36
    fox0001  
       2018-12-09 20:37:06 +08:00 via Android   ❤️ 1
    我一般选择类似方案 2 的做法。但数据库设计肯定是采用关系表,已读表存放用户 id 和帖子 id。

    如果帖子数量很大的话,而且查询又频繁,就考虑弄个缓存,记录用户未读帖子分类和数量,再弄个队列延时更新之类。

    至于代码的安排,就是
    1 ) controller 接收查询条件,调用 service 方法并返回结果
    2 ) service 查询接口,检验数据,处理业务逻辑,数据查询调用 dao 的查询方法
    3 ) dao 查询接口,相关查询语句,即与数据库的交互都写在这里,查询结果封装成对象返回
    no1xsyzy
        37
    no1xsyzy  
       2018-12-09 20:39:14 +08:00
    @V2XEX 自底向上编程,请。
    ——当有一次写出的代码明明和需求不符但运行得很好有感。
    如果你从打印一个 hello world 开始就是库+胶水代码,那么打印 10 次也不那么难,循环特定次数也不过是把 10 变成输入项,附带输入内容也可以随手写个 format。
    V2XEX
        38
    V2XEX  
    OP
       2018-12-09 20:59:52 +08:00 via Android
    @no1xsyzy 我只是举个例子而已……那以后我还要加其他东西呢?你势必要写其他的方法、类,把可重用的东西抽象,这个谁都知道。
    如果你开始就知道要接收用户输入按需打印,那你大可以规划一个输入模块,一个计算打印内容的模块,一个打印模块等,代码不仅井井有条、漂漂亮亮,还利于维护拓展,这就是你说的“模块划分很好”了,但是在开始做的时候没人告诉这些,加上时间紧任务重,我想是个正常人都直接写个 system.out.print (“ hello world ”),以后改什么直接在上面加,这样久而久之垃圾代码就出现了…

    还有,你自己给自己定的需求,别说过段时间改一次了……可能这一秒跟下一秒是完全不同的两个想法,那直接抄起键盘就开干,但是你摸良心说说这和客户\产品经理给你改的需求是一回事不……
    jlkm2010
        39
    jlkm2010  
       2018-12-10 10:52:20 +08:00
    打死那个设计表字段的,瞎胡搞
    no1xsyzy
        40
    no1xsyzy  
       2018-12-10 13:50:59 +08:00   ❤️ 1
    @V2XEX
    > 以后改什么直接在上面加
    这就是问题
    我举的例子是没有 print 函数的情况,那我会先写个 string->None 的 print 函数出来
    要加个数字就弄一个 int->string 的 format 函数,第二个参数来了依照来源做 fetcher 然后套进 format 里。
    然后主函数就变成了 print(format(fetcher1(), fetcher2(), fetcher3))
    主函数从来不写长,而且因为上述嵌套函数过多,我很想能够 (fetcher1, fetcher2, fetcher3)|f[_()]|format|print 这样写。

    我想说的是,作为基础能力,在比较微小且直接的问题上能够很快地抽象
    为什么一跑到巨大而间接的问题就失去了这种能力?
    这说明你的思路从开始就是一团乱麻,小问题上的抽象只是见过这种抽象所以能做。
    这就好像说数学题:数字变了变就不会做 vs 数字变了模式没变还会做 vs 数字变了导致模式变了还可能会做。

    > 当有一次写出的代码明明和需求不符但运行得很好有感。
    那次改需求,结果我听完把原需求和新需求都实现了,API 形状拓展但保留兼容,按需调用,并且因此导致其实需求没传达清楚但能用。
    具体来说,改的时候,告诉我一个 API 需要验证文件 sha3 (来决定是否更新),但其实验证的是 sha384。然而我直接把接口变成 {origname}.{type}(比如 foo.exe.sha384sum ),直接丢过去正常用了,后来说到其实是 sha384 才知道有错。框架也就用了不到一个月,基本上一个函数查 5 次文档,但 API 感觉在那,我能怎么办?
    大概有运气的成分,但能碰到这运气也是有对 API 形状的直觉所致。

    可能主要是因为我从犯中二病开始就一直纠结于这些事,到系统学习编程(高中 NOIP )之前已经想了大概 5 年吧。
    V2XEX
        41
    V2XEX  
    OP
       2018-12-10 20:53:07 +08:00
    @no1xsyzy 我想了下,感到这确实也是个经验问题。
    开始我假设的情况是“定需求"这个环节能做到最好,那么开发过程中很多看似由程序员造的坑即可避免(当然,在这种需求都给清楚了的情况下,程序员还能犯错那自然是难辞其咎的)。

    然而我假设的这种条件是苛刻的,作为一个开发者自然不能去要求其他人(甚至是上司、客户),把“定需求”这个环境做得完美,在实现需求的过程中要做的工作也不止”按图画画“这么简单,有经验的人听到别人说了 1+1 自己马上能想到 10+10 甚至开始为 10x10 做准备了,我在写代码前确实“想”得少了,就自身来说还是个经验问题。

    下次写代码前还是得多花时间去“想”,这样在起步阶段也许会慢,但是把这个工作做了,那项目将会更健壮,容错率也更高。
    dezhou9
        42
    dezhou9  
       2018-12-23 12:52:41 +08:00 via Android
    放到哪里都不对,小项目没那么多人做后端和 db,大项目复杂逻辑不该用 MySQL 了。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   3454 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 28ms · UTC 10:30 · PVG 18:30 · LAX 02:30 · JFK 05:30
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.