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

2018-12-08 19:57:52 +08:00
 V2XEX

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 指教下

6918 次点击
所在节点    Java
42 条回复
barryng67
2018-12-08 22:38:19 +08:00
一般弄个冗余字段存数,自己写逻辑维护,这样效率高点,数据量大也不怕。
lihongjie0209
2018-12-08 22:41:32 +08:00
如果架构设计足够好, 封装度足够高, 那么在你的概念中都不应该出现 sql 这个东西, 都是细节
TomVista
2018-12-08 22:45:50 +08:00
对比下 io 成本和计算成本,然后选合适的
Kiske
2018-12-08 22:50:25 +08:00
是两个问题: 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
2018-12-08 22:53:23 +08:00
@MegrezZhu
不是啊……
1 将已读用户写入帖子表意思是把 uuid 写入帖子表的 readers 字段并用逗号分隔,比如像这样:uuid1,uuid2,uuid3
某个帖子每被浏览一次就更新对应帖子的这个字段
2 删除是指帖子可能会被删除,而不是删除浏览记录,如果有中间表那对应帖子的所有浏览记录都得删,不知对比将帖子整个删除这是否是个额外的花销(软删除同理)
MegrezZhu
2018-12-08 23:10:00 +08:00
@V2XEX
第一点的话,上面的 @Kiske 讲得很好。
第二点,采用访问记录表的话的确会有额外开销,但我认为这在大部分场景下是完全可以接受的。而如果删除成为瓶颈的话,软删除的方案挺好的。
V2XEX
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
2018-12-08 23:28:06 +08:00
用户日活一百左右的话,用这个方案没问题
no1xsyzy
2018-12-09 00:40:06 +08:00
@V2XEX
我不太清楚各个数据库实现上有什么区别,但字符串应该是顺序存储在一块内的吧。
也就是说在删除后肯定会产生不规则形状的洞。这些洞要被有效利用上肯定还是要移动其他数据的。

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

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

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

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

还有,你自己给自己定的需求,别说过段时间改一次了……可能这一秒跟下一秒是完全不同的两个想法,那直接抄起键盘就开干,但是你摸良心说说这和客户\产品经理给你改的需求是一回事不……
jlkm2010
2018-12-10 10:52:20 +08:00
打死那个设计表字段的,瞎胡搞
no1xsyzy
2018-12-10 13:50:59 +08:00
@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 年吧。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/515705

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX